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 -%}
{{ 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/Theme/ThemeUiTest.php b/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php new file mode 100644 index 0000000000..cdaaea5227 --- /dev/null +++ b/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php @@ -0,0 +1,167 @@ +drupalLogin($this->adminUser = $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) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->drupalGet('admin/appearance'); + $expected_requires_list_items = [ + 'Test Module Required by Theme (disabled)', + 'Test Another Module Required by Theme (disabled)', + ]; + $this->assertUninstallableTheme($expected_requires_list_items, $theme_name); + + // Enable one of the two required modules. + $this->drupalPostForm('admin/modules', [ + 'modules[test_module_required_by_theme][enable]' => 1, + ], 'Install'); + $this->assertSession()->elementExists('css', '#edit-modules-test-module-required-by-theme-enable[checked]'); + + $this->drupalGet('admin/appearance'); + + // Confirm the theme is still uninstallable due to a remaining module + // dependency. + $expected_requires_list_items = [ + 'Test Another Module Required by Theme (disabled)', + ]; + $this->assertUninstallableTheme($expected_requires_list_items, $theme_name); + + $this->drupalPostForm('admin/modules', [ + 'modules[test_another_module_required_by_theme][enable]' => 1, + ], 'Install'); + $this->assertSession()->elementExists('css', '#edit-modules-test-another-module-required-by-theme-enable[checked]'); + $this->assertSession()->elementExists('css', '#edit-modules-test-module-required-by-theme-enable[checked]'); + + // The theme should now be installable, so install it. + $this->drupalGet('admin/appearance'); + $page->clickLink('Install Test Theme Depending on Modules theme'); + $assert_session->addressEquals('admin/appearance'); + $assert_session->pageTextContains('The Test Theme Depending on Modules 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'); + $assert_session->elementExists('css', '[name="uninstall[test_module_required_by_theme]"][disabled]'); + $assert_session->elementExists('css', '[name="uninstall[test_another_module_required_by_theme]"][disabled]'); + $assert_session->elementTextContains('css', '[data-drupal-selector="edit-test-another-module-required-by-theme"] .item-list', 'Required by the theme: test theme depending on modules'); + $assert_session->elementTextContains('css', '[data-drupal-selector="edit-test-module-required-by-theme"] .item-list', 'Required by the theme: test theme depending on modules'); + + // Uninstall the theme that depends on the modules, and confirm the modules + // can now be uninstalled. + $this->drupalGet('admin/appearance'); + $this->clickLink('Uninstall Test Theme Depending on Modules theme'); + $assert_session->pageTextContains('The Test Theme Depending on Modules theme has been uninstalled.'); + $this->drupalGet('admin/modules/uninstall'); + $assert_session->elementExists('css', '[name="uninstall[test_module_required_by_theme]"]:not([disabled])'); + $assert_session->elementExists('css', '[name="uninstall[test_another_module_required_by_theme]"]:not([disabled])'); + $this->drupalPostForm('admin/modules/uninstall', [ + 'uninstall[test_module_required_by_theme]' => 1, + 'uninstall[test_another_module_required_by_theme]' => 1, + ], 'Uninstall'); + $confirmation_message = 'The following modules will be completely uninstalled from your site, and all data from these modules will be lost!test another module required by themetest module required by themeWould you like to continue with uninstalling the above?'; + $assert_session->pageTextContains($confirmation_message); + $page->pressButton('Uninstall'); + $assert_session->pageTextContains('The selected modules have been uninstalled.'); + } + + /** + * Data provider for testThemeInstallWithModuleDependencies(). + */ + public function providerTestThemeInstallWithModuleDependencies() { + return [ + 'test theme depending on modules' => ['Test Theme Depending on Modules'], + 'test theme with a base theme depending on modules' => ['Test Theme with a Base Theme Depending on Modules'], + ]; + } + + /** + * 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->find('css', '.theme-info__requires li'); + + 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('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_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..e0027056be 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; @@ -104,21 +106,11 @@ public function testInstallSubTheme() { */ public function testInstallNonExisting() { $name = 'non_existing_theme'; - - $themes = $this->themeHandler()->listInfo(); - $this->assertEmpty(array_keys($themes)); - - try { - $message = 'ThemeInstaller::install() throws UnknownExtensionException upon installing a non-existing theme.'; - $this->themeInstaller()->install([$name]); - $this->fail($message); - } - catch (UnknownExtensionException $e) { - $this->pass(get_class($e) . ': ' . $e->getMessage()); - } - $themes = $this->themeHandler()->listInfo(); $this->assertEmpty(array_keys($themes)); + $this->expectException(UnknownExtensionException::class); + $this->expectExceptionMessage('Unknown themes: non_existing_theme.'); + $this->themeInstaller()->install([$name]); } /** @@ -137,6 +129,34 @@ public function testInstallNameTooLong() { } } + /** + * Tests installing a theme with unmet module dependencies. + */ + public function testInstallThemeWithUnmetModuleDependencies() { + $name = 'test_theme_depending_on_modules'; + $themes = $this->themeHandler()->listInfo(); + $this->assertEmpty(array_keys($themes)); + $this->expectException(MissingDependencyException::class); + $this->expectExceptionMessage('Unable to install theme: \'test_theme_depending_on_modules\' due to missing module dependencies: \'test_module_required_by_theme, test_another_module_required_by_theme.'); + $this->themeInstaller()->install([$name]); + } + + /** + * 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 @@