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