diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt index 00af16fb88ab7eb826c4d616ea935a170270bab1..c9c2091cab2dbf0f191fabc6c1cace8fac557efa 100644 --- a/.cspell-project-words.txt +++ b/.cspell-project-words.txt @@ -5,3 +5,4 @@ sirv yarncheck colinodell testlogger +kanopi diff --git a/composer.json b/composer.json index b66cdb38813ee910ee92ff0091b4d03ac75e0a1a..a824a0bb2a385ecb8c8376db0a4b4aa031d164d5 100644 --- a/composer.json +++ b/composer.json @@ -5,13 +5,16 @@ "license": "GPL-2.0-or-later", "require": { "php": ">=8.1", - "guzzlehttp/guzzle": "^6 || ^7", + "ext-simplexml": "*", + "composer-runtime-api": "^2", "composer/semver": "^3.2", - "ext-simplexml": "*" + "guzzlehttp/guzzle": "^6 || ^7", + "symfony/finder": "^6.3 || ^7" }, "require-dev": { + "colinodell/psr-testlogger": "^1.2", "drupal/automatic_updates": "^3.1.2", - "colinodell/psr-testlogger": "^1.2" + "kanopi/imagemagick-configuration": "@dev" }, "conflict": { "drupal/automatic_updates": "<3.0" diff --git a/images/recipe-logo.png b/images/recipe-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..db5b58b2a026ebcea47ee2b16151b8acc664d552 Binary files /dev/null and b/images/recipe-logo.png differ diff --git a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php index 896e5c6774a23a57c5e74ec4c6775bd0c587181b..7abcf406ffb90fc45449bbab5f2511eba5a7cbe1 100644 --- a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php +++ b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php @@ -135,7 +135,7 @@ class RandomDataPlugin extends ProjectBrowserSourceBase { $projects = array_filter($projects, fn(Project $project) => stripos($project->title, $query['search']) !== FALSE); } - return $this->createResultsPage($projects, TRUE); + return $this->createResultsPage($projects); } /** @@ -192,7 +192,6 @@ class RandomDataPlugin extends ProjectBrowserSourceBase { 'value' => $this->randomGenerator->paragraphs(5), ], title: ucwords($machine_name), - status: rand(0, 1), changed: $this->getRandomDate(), created: $this->getRandomDate(), author: [ diff --git a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php index cc5d84a40df3065b16a36f8fd2b281ec4a80e3e3..959711f1f0c182d06ff0fc90632776b4456652a3 100644 --- a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php +++ b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php @@ -149,8 +149,6 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase { 'value' => $project_from_source['long_description'], ], title: $project_from_source['label'], - // Status: 1 enabled / 0 disabled. - status: 1, changed: $project_from_source['updated_at'], created: $project_from_source['created_at'], author: $author, @@ -176,8 +174,6 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase { 'value' => $project_from_source['long_description'] . ' (different commands)', ], title: 'A project with different commands', - // Status: 1 enabled / 0 disabled. - status: 1, changed: $project_from_source['updated_at'], created: $project_from_source['created_at'], author: $author, @@ -190,7 +186,7 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase { // Return one page of results. The first parameter is the total number of // results for the set, as filtered by $query. - return $this->createResultsPage($projects, TRUE); + return $this->createResultsPage($projects); } /** diff --git a/phpstan.neon b/phpstan.neon index f715d17e092ac016d8c78d8aa301d7e5f57c31ce..7e8cbc5c36ba345aac6dc91c9bbf2267a8724c0f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,3 +9,24 @@ parameters: - # @see https://www.drupal.org/docs/develop/development-tools/phpstan/handling-unsafe-usage-of-new-static#s-ignoring-the-issue identifier: new.static + + # @todo: Remove the following rules when support is dropped for Drupal 10.2, which does not have recipes. + - + message: "#^Access to constant COMPOSER_PROJECT_TYPE on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe\\.$#" + paths: + - src/Plugin/ProjectBrowserSource/Recipes.php + - src/RecipeActivator.php + - tests/src/Kernel/RecipesSourceTest.php + reportUnmatched: false + - + message: "#^Call to static method [a-zA-Z]+\\(\\) on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe[a-zA-Z]*\\.$#" + path: src/RecipeActivator.php + reportUnmatched: false + - + message: "#^Class Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent not found\\.$#" + path: src/RecipeActivator.php + reportUnmatched: false + - + message: "#^Parameter \\$event of method Drupal\\\\project_browser\\\\RecipeActivator\\:\\:onApply\\(\\) has invalid type Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent\\.$#" + path: src/RecipeActivator.php + reportUnmatched: false diff --git a/project_browser.install b/project_browser.install index 824cf993742c45f22467a493aff8ce1688b0ea2a..cb07f6e646078202ccb7612e8267cfcec537831f 100644 --- a/project_browser.install +++ b/project_browser.install @@ -6,6 +6,7 @@ */ use Drupal\Core\Database\Database; +use Drupal\Core\Recipe\Recipe; /** * Implements hook_schema(). @@ -135,6 +136,14 @@ function project_browser_schema() { */ function project_browser_install() { _project_browser_populate_from_fixture(); + + if (class_exists(Recipe::class)) { + $config = \Drupal::configFactory() + ->getEditable('project_browser.admin_settings'); + $enabled_sources = $config->get('enabled_sources'); + $enabled_sources[] = 'recipes'; + $config->set('enabled_sources', $enabled_sources)->save(); + } } /** diff --git a/project_browser.module b/project_browser.module index 24e5fba4e98bae445c6697547e8d2ff0e73181f2..f47f581b7cfea4d0bb85f4e34f7533a37544529e 100644 --- a/project_browser.module +++ b/project_browser.module @@ -5,8 +5,10 @@ * Provides hook implementations for the Project Browser module. */ +use Drupal\Core\Recipe\Recipe; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Url; +use Drupal\project_browser\Plugin\ProjectBrowserSource\Recipes; use Drupal\project_browser\ProjectBrowserFixtureHelper; /** @@ -60,3 +62,17 @@ function project_browser_menu_links_discovered_alter(&$links) { unset($links['admin_toolbar_tools.extra_links:update.module_install']); } } + +/** + * Implements hook_project_browser_source_info_alter(). + */ +function project_browser_project_browser_source_info_alter(array &$definitions): void { + if (class_exists(Recipe::class)) { + $definitions['recipes'] = [ + 'id' => 'recipes', + 'label' => t('Recipes'), + 'description' => t('Shows recipes available in the local code base.'), + 'class' => Recipes::class, + ]; + } +} diff --git a/src/ActivationInstructionsTrait.php b/src/ActivationInstructionsTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..ff2683bb17a88eb527eaec59e806984515d8d45a --- /dev/null +++ b/src/ActivationInstructionsTrait.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser; + +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\File\FileUrlGeneratorInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * Provides helper methods for activators which generate instructions. + */ +trait ActivationInstructionsTrait { + + use StringTranslationTrait; + + public function __construct( + protected readonly ModuleExtensionList $moduleList, + protected readonly FileUrlGeneratorInterface $fileUrlGenerator, + ) { + } + + /** + * Generates the markup for a copy-and-paste terminal command. + * + * @param string $command + * A terminal command. + * @param string $action + * An identifier of the action, like `download` or `run`. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $alt + * (optional) The alt text of the "copy" button. Defaults to "Copy the + * $action command". + * + * @return string + * The given command, in a format that can be copied and pasted. + */ + protected function commandBox(string $command, string $action, ?TranslatableMarkup $alt = NULL): string { + $alt ??= $this->t('Copy the @action command', ['@action' => $action]); + + $icon_url = $this->moduleList->getPath('project_browser') . '/images/copy-icon.svg'; + $icon_url = $this->fileUrlGenerator->generateString($icon_url); + + $command_box = '<div class="command-box">'; + $command_box .= '<input value="' . $command . '" readonly />'; + $command_box .= '<button data-copy-command id="' . $action . '-btn">'; + $command_box .= '<img src="' . $icon_url . '" alt="' . $alt . '" />'; + $command_box .= '</button>'; + $command_box .= '</div>'; + return $command_box; + } + +} diff --git a/src/ActivationStatus.php b/src/ActivationStatus.php new file mode 100644 index 0000000000000000000000000000000000000000..12ba43a43a894bac66fe3a9ebec196b8aa5dd86c --- /dev/null +++ b/src/ActivationStatus.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser; + +/** + * Defines the possible states of a project in the current site. + */ +enum ActivationStatus { + + // Not physically present, but can be required and activated. + case Absent; + // Physically present, but not yet activated. + case Present; + // Physically present and activated. + case Active; + +} diff --git a/src/Activator.php b/src/Activator.php index ae687986797b00f8336159559a9679455efdb2dc..19245c186ca33fb80026afc2e28d7b9789adac28 100644 --- a/src/Activator.php +++ b/src/Activator.php @@ -60,8 +60,8 @@ final class Activator implements ActivatorInterface { /** * {@inheritdoc} */ - public function isActive(Project $project): bool { - return $this->getActivatorForProject($project)->isActive($project); + public function getStatus(Project $project): ActivationStatus { + return $this->getActivatorForProject($project)->getStatus($project); } /** diff --git a/src/ActivatorInterface.php b/src/ActivatorInterface.php index faa635101fa9cc4fdb5d904bfa159b31b7b5e550..b174ae34b7a8950f4beb6019b3fa93feb5d5cfeb 100644 --- a/src/ActivatorInterface.php +++ b/src/ActivatorInterface.php @@ -23,10 +23,10 @@ interface ActivatorInterface { * @param \Drupal\project_browser\ProjectBrowser\Project $project * A project to check. * - * @return bool - * TRUE if the project is activated on the current site, FALSE otherwise. + * @return \Drupal\project_browser\ActivationStatus + * The state of the project on the current site. */ - public function isActive(Project $project): bool; + public function getStatus(Project $project): ActivationStatus; /** * Determines if this activator can handle a particular project. diff --git a/src/Controller/BrowserController.php b/src/Controller/BrowserController.php index eb4e3d2ce2af1a01d91c7f0d7c30ef3c251aa75f..5874890afee4a8bd0cc82460138e52ece473f31f 100644 --- a/src/Controller/BrowserController.php +++ b/src/Controller/BrowserController.php @@ -3,7 +3,6 @@ namespace Drupal\project_browser\Controller; use Drupal\Core\Controller\ControllerBase; -use Drupal\Core\Extension\InfoParserException; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\project_browser\DevelopmentStatus; use Drupal\project_browser\EnabledSourceHandler; @@ -71,7 +70,6 @@ class BrowserController extends ControllerBase { * A render array. */ public function browse($module_name) { - $modules_status = $this->getModuleStatuses(); $request = $this->requestStack->getCurrentRequest(); $current_sources = $this->enabledSource->getCurrentSources(); $ui_install_enabled = (bool) $this->config('project_browser.admin_settings')->get('allow_ui_install') && (bool) $this->installReadiness; @@ -101,7 +99,6 @@ class BrowserController extends ControllerBase { 'drupalSettings' => [ 'project_browser' => [ 'active_plugins' => $active_plugins, - 'modules' => $modules_status, 'drupal_version' => \Drupal::VERSION, 'drupal_core_compatibility' => \Drupal::CORE_COMPATIBILITY, 'module_path' => $this->moduleHandler()->getModule('project_browser')->getPath(), @@ -116,6 +113,7 @@ class BrowserController extends ControllerBase { 'ui_install' => $ui_install_enabled, 'stage_available' => $ui_install_enabled ? $this->installReadiness->installerAvailable() : FALSE, 'pm_validation' => $ui_install_enabled ? $this->installReadiness->validatePackageManager() : TRUE, + 'package_manager_available' => array_key_exists('package_manager', $this->moduleList->getAllInstalledInfo()), ], ], ], @@ -148,28 +146,4 @@ class BrowserController extends ControllerBase { ]; } - /** - * Gets all module statuses. - * - * @return array - * An array of module statues, keyed by machine name. - */ - protected function getModuleStatuses(): array { - // Sort all modules by their names. - try { - // The module list needs to be reset so that it can re-scan and include - // any new modules that may have been added directly into the filesystem. - $modules = $this->moduleList->reset()->getList(); - uasort($modules, [ModuleExtensionList::class, 'sortByName']); - } - catch (InfoParserException $e) { - $this->messenger()->addError($this->t('Modules could not be listed due to an error: %error', ['%error' => $e->getMessage()])); - $modules = []; - } - - return array_map(function ($value) { - return $value->status; - }, $modules); - } - } diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php index 6d706ba5b259dd9eaa9bf132e74d1b1901c1da11..4a7396ee3aa8b628fe62efd8c52a9a6a95889ae6 100644 --- a/src/Controller/ProjectBrowserEndpointController.php +++ b/src/Controller/ProjectBrowserEndpointController.php @@ -158,6 +158,9 @@ class ProjectBrowserEndpointController extends ControllerBase { foreach ($projects as $result_page) { foreach ($result_page->list as $project) { + // The project's activator is the source of truth about the status of + // the project with respect to the current site. + $project->status = $this->activator->getStatus($project); // The activator is responsible for generating the instructions. $project->commands = $this->activator->getInstructions($project); } diff --git a/src/ModuleActivator.php b/src/ModuleActivator.php index e96868010ea7518cc6281a05c8b3baeeeb6a9325..e001e9350d2fb4b4ab9f6360b5f05af9985bd727 100644 --- a/src/ModuleActivator.php +++ b/src/ModuleActivator.php @@ -7,8 +7,6 @@ namespace Drupal\project_browser; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Extension\ModuleInstallerInterface; use Drupal\Core\File\FileUrlGeneratorInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; -use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Url; use Drupal\project_browser\ProjectBrowser\Project; use Symfony\Component\HttpFoundation\Response; @@ -18,19 +16,29 @@ use Symfony\Component\HttpFoundation\Response; */ final class ModuleActivator implements ActivatorInterface { - use StringTranslationTrait; + use ActivationInstructionsTrait { + __construct as traitConstruct; + } public function __construct( - private readonly ModuleExtensionList $moduleList, private readonly ModuleInstallerInterface $moduleInstaller, - private readonly FileUrlGeneratorInterface $fileUrlGenerator, - ) {} + ModuleExtensionList $moduleList, + FileUrlGeneratorInterface $fileUrlGenerator, + ) { + $this->traitConstruct($moduleList, $fileUrlGenerator); + } /** * {@inheritdoc} */ - public function isActive(Project $project): bool { - return array_key_exists($project->machineName, $this->moduleList->getAllInstalledInfo()); + public function getStatus(Project $project): ActivationStatus { + if (array_key_exists($project->machineName, $this->moduleList->getAllInstalledInfo())) { + return ActivationStatus::Active; + } + elseif (array_key_exists($project->machineName, $this->moduleList->getAllAvailableInfo())) { + return ActivationStatus::Present; + } + return ActivationStatus::Absent; } /** @@ -52,7 +60,7 @@ final class ModuleActivator implements ActivatorInterface { * {@inheritdoc} */ public function getInstructions(Project $project): string|Url|null { - if (array_key_exists($project->machineName, $this->moduleList->getAllAvailableInfo())) { + if ($this->getStatus($project) === ActivationStatus::Present) { return Url::fromRoute('system.modules_list', options: [ 'fragment' => 'module-' . str_replace('_', '-', $project->machineName), ]); @@ -96,33 +104,4 @@ final class ModuleActivator implements ActivatorInterface { return $commands; } - /** - * Generates the markup for a copy-and-paste terminal command. - * - * @param string $command - * A terminal command. - * @param string $action - * An identifier of the action, like `download` or `run`. - * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $alt - * (optional) The alt text of the "copy" button. Defaults to "Copy the - * $action command". - * - * @return string - * The given command, in a format that can be copied and pasted. - */ - private function commandBox(string $command, string $action, ?TranslatableMarkup $alt = NULL): string { - $alt ??= $this->t('Copy the @action command', ['@action' => $action]); - - $icon_url = $this->moduleList->getPath('project_browser') . '/images/copy-icon.svg'; - $icon_url = $this->fileUrlGenerator->generateString($icon_url); - - $command_box = '<div class="command-box">'; - $command_box .= '<input value="' . $command . '" readonly />'; - $command_box .= '<button data-copy-command id="' . $action . '-btn">'; - $command_box .= '<img src="' . $icon_url . '" alt="' . $alt . '" />'; - $command_box .= '</button>'; - $command_box .= '</div>'; - return $command_box; - } - } diff --git a/src/Plugin/ProjectBrowserSource/DrupalCore.php b/src/Plugin/ProjectBrowserSource/DrupalCore.php index 66bd1404bf8a6be94c6535eb854353dcd5a644cb..a839ac053777cf78da2536ca475050c34a295dbb 100644 --- a/src/Plugin/ProjectBrowserSource/DrupalCore.php +++ b/src/Plugin/ProjectBrowserSource/DrupalCore.php @@ -159,7 +159,7 @@ class DrupalCore extends ProjectBrowserSourceBase { if (!empty($query['page']) && !empty($query['limit'])) { $projects = array_chunk($projects, $query['limit'])[$query['page']] ?? []; } - return $this->createResultsPage($projects, FALSE, $project_count); + return $this->createResultsPage($projects, $project_count); } /** @@ -200,7 +200,6 @@ class DrupalCore extends ProjectBrowserSourceBase { 'value' => $module->info['description'], ], title: $module->info['name'], - status: $module->status, changed: 280299600, created: 280299600, author: [ diff --git a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php index 2f5eac49f3b09b3bd6cdd40e4383aefae5107c7f..6b26ac60d0e520f4b9c2a87ec6c286a186844a54 100644 --- a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php +++ b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php @@ -384,7 +384,6 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase { machineName: $project_data['field_project_machine_name'], body: $this->relativeToAbsoluteUrls($project_data['project_data']['body'], 'https://www.drupal.org'), title: $project_data['title'], - status: $project_data['status'], changed: $project_data['changed'], created: $project_data['created'], author: ['name' => $project_data['author']], @@ -398,7 +397,7 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase { } } - return $this->createResultsPage($returned_list, TRUE, $api_response['total_results'] ?? 0); + return $this->createResultsPage($returned_list, $api_response['total_results'] ?? 0); } /** diff --git a/src/Plugin/ProjectBrowserSource/Recipes.php b/src/Plugin/ProjectBrowserSource/Recipes.php new file mode 100644 index 0000000000000000000000000000000000000000..54e0bad5c2e671e1329186e82ad5e874ff0e5a3a --- /dev/null +++ b/src/Plugin/ProjectBrowserSource/Recipes.php @@ -0,0 +1,194 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser\Plugin\ProjectBrowserSource; + +use Composer\InstalledVersions; +use Drupal\Component\Serialization\Json; +use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\File\FileUrlGeneratorInterface; +use Drupal\Core\Recipe\Recipe; +use Drupal\project_browser\Plugin\ProjectBrowserSourceBase; +use Drupal\project_browser\ProjectBrowser\Project; +use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage; +use Drupal\project_browser\ProjectType; +use Drupal\project_browser\SecurityStatus; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Finder\Finder; + +/** + * A source plugin that exposes recipes installed locally. + */ +class Recipes extends ProjectBrowserSourceBase { + + public function __construct( + private readonly FileSystemInterface $fileSystem, + private readonly CacheBackendInterface $cacheBin, + private readonly ModuleExtensionList $moduleList, + private readonly FileUrlGeneratorInterface $fileUrlGenerator, + private readonly string $appRoot, + mixed ...$arguments, + ) { + parent::__construct(...$arguments); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + return new static( + $container->get(FileSystemInterface::class), + $container->get('cache.project_browser'), + $container->get(ModuleExtensionList::class), + $container->get(FileUrlGeneratorInterface::class), + $container->getParameter('app.root'), + ...array_slice(func_get_args(), 1), + ); + } + + /** + * {@inheritdoc} + */ + public function getProjects(array $query = []): ProjectsResultsPage { + $cached = $this->cacheBin->get($this->getPluginId()); + if ($cached) { + $projects = $cached->data; + } + else { + $projects = []; + + $logo_url = $this->moduleList->getPath('project_browser') . '/images/recipe-logo.png'; + $logo_url = $this->fileUrlGenerator->generateString($logo_url); + + /** @var \Symfony\Component\Finder\SplFileInfo $file */ + foreach ($this->getFinder() as $file) { + $path = $file->getPath(); + + // If the recipe isn't part of Drupal core, get its package name from + // `composer.json`. This shouldn't be necessary once drupal.org has a + // proper API endpoint that provides project information for recipes. + if (str_starts_with($path, $this->appRoot . '/core/recipes/')) { + $package_name = 'drupal/core'; + } + else { + $package = file_get_contents($path . '/composer.json'); + $package = Json::decode($package); + $package_name = $package['name']; + } + + $recipe = Yaml::decode($file->getContents()); + $description = $recipe['description'] ?? NULL; + + $projects[] = new Project( + logo: [ + 'file' => [ + 'uri' => $logo_url, + 'resource' => 'image', + ], + 'alt' => (string) $this->t('@name logo', [ + '@name' => $recipe['name'], + ]), + ], + isCompatible: TRUE, + isMaintained: TRUE, + isCovered: TRUE, + isActive: TRUE, + starUserCount: 0, + projectUsageTotal: 0, + machineName: basename($path), + body: $description ? ['value' => $description] : [], + title: $recipe['name'], + changed: 0, + created: 0, + author: [], + packageName: $package_name, + type: ProjectType::Recipe, + ); + } + $this->cacheBin->set($this->getPluginId(), $projects); + } + + $total = count($projects); + + // Filter by project machine name. + if (!empty($query['machine_name'])) { + $projects = array_filter($projects, fn(Project $project) => $project->machineName === $query['machine_name']); + } + + // Filter by coverage. + if (!empty($query['security_advisory_coverage']) && $query['security_advisory_coverage'] === SecurityStatus::Covered->value) { + $projects = array_filter($projects, fn(Project $project) => $project->isCovered); + } + + // Filter by categories. + if (!empty($query['categories'])) { + $projects = array_filter($projects, fn(Project $project) => array_intersect(array_column($project->categories, 'id'), explode(',', $query['categories']))); + } + + // Filter by search text. + if (!empty($query['search'])) { + $projects = array_filter($projects, fn(Project $project) => stripos($project->title, $query['search']) !== FALSE); + } + + // Filter by sorting criterion. + if (!empty($query['sort'])) { + $sort = $query['sort']; + switch ($sort) { + case 'a_z': + usort($projects, fn($x, $y) => $x->title <=> $y->title); + break; + + case 'z_a': + usort($projects, fn($x, $y) => $y->title <=> $x->title); + break; + } + } + + if (array_key_exists('page', $query) && !empty($query['limit'])) { + $projects = array_chunk($projects, $query['limit'])[$query['page']] ?? []; + } + + return $this->createResultsPage($projects, $total); + } + + /** + * Prepares a Symfony Finder to search for recipes in the file system. + * + * @return \Symfony\Component\Finder\Finder + * A Symfony Finder object, configured to find locally installed recipes. + */ + private function getFinder(): Finder { + $search_in = [$this->appRoot . '/core/recipes']; + + // If any recipes have been installed by Composer, also search there. The + // recipe system requires that all non-core recipes be located next to each + // other, in the same directory. + $contrib_recipe_names = InstalledVersions::getInstalledPackagesByType(Recipe::COMPOSER_PROJECT_TYPE); + if ($contrib_recipe_names) { + $path = InstalledVersions::getInstallPath($contrib_recipe_names[0]); + $path = $this->fileSystem->realpath($path); + + $search_in[] = dirname($path); + } + + return Finder::create() + ->files() + ->in($search_in) + ->depth(1) + // The example recipe exists for documentation purposes only. + ->notPath('example/') + ->name('recipe.yml'); + } + + /** + * {@inheritdoc} + */ + public function getCategories(): array { + return []; + } + +} diff --git a/src/Plugin/ProjectBrowserSourceBase.php b/src/Plugin/ProjectBrowserSourceBase.php index a003cfacf9bbf3335bba750e5d413978f05b50e1..6d41c41b35650cefd165310b241aa7475c419c88 100644 --- a/src/Plugin/ProjectBrowserSourceBase.php +++ b/src/Plugin/ProjectBrowserSourceBase.php @@ -66,21 +66,18 @@ abstract class ProjectBrowserSourceBase extends PluginBase implements ProjectBro * * @param \Drupal\project_browser\ProjectBrowser\Project[] $results * The projects to list on the page. - * @param bool $package_manager_required - * Whether Package Manager is required for these projects. * @param int|null $total_results * (optional) The total number of results. Defaults to the size of $results. * * @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage * A list of projects to send to the client. */ - protected function createResultsPage(array $results, bool $package_manager_required, ?int $total_results = NULL): ProjectsResultsPage { + protected function createResultsPage(array $results, ?int $total_results = NULL): ProjectsResultsPage { return new ProjectsResultsPage( $total_results ?? count($results), array_values($results), (string) $this->getPluginDefinition()['label'], $this->getPluginId(), - $package_manager_required, ); } diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php index e571ca97ece2b20c3bf3669eddcc44a3eb55bd05..07951f7dccb16060a2b5dba6f3ed057dadba6c10 100644 --- a/src/ProjectBrowser/Project.php +++ b/src/ProjectBrowser/Project.php @@ -6,6 +6,7 @@ use Drupal\Component\Utility\Html; use Drupal\Component\Utility\Unicode; use Drupal\Component\Utility\Xss; use Drupal\Core\Url; +use Drupal\project_browser\ActivationStatus; use Drupal\project_browser\ProjectType; /** @@ -20,6 +21,13 @@ class Project implements \JsonSerializable { */ public readonly string $id; + /** + * The status of this project in the current site. + * + * @var \Drupal\project_browser\ActivationStatus + */ + public ActivationStatus $status; + /** * The instructions, if any, to activate this project. * @@ -59,8 +67,6 @@ class Project implements \JsonSerializable { * Body field of the project in array format. * @param string $title * Title of the project. - * @param int $status - * Status of the project. * @param int $changed * When was the project changed last timestamp. * @param int $created @@ -95,7 +101,6 @@ class Project implements \JsonSerializable { public string $machineName, private array $body, public string $title, - public int $status, public int $changed, public int $created, public array $author, @@ -182,7 +187,11 @@ class Project implements \JsonSerializable { 'is_active' => $this->isActive, 'flag_project_star_user_count' => $this->starUserCount, 'url' => $this->url, - 'status' => $this->status, + 'status' => match ($this->status) { + ActivationStatus::Absent => 'absent', + ActivationStatus::Present => 'present', + ActivationStatus::Active => 'active', + }, 'changed' => $this->changed, 'created' => $this->created, 'selector_id' => $this->getSelectorId(), diff --git a/src/ProjectBrowser/ProjectsResultsPage.php b/src/ProjectBrowser/ProjectsResultsPage.php index 5f520abaa9dfc97a22e9e79f453cb62306e3d9a6..8a23685db107af0b9036afdbebb30f4e400cb4b2 100644 --- a/src/ProjectBrowser/ProjectsResultsPage.php +++ b/src/ProjectBrowser/ProjectsResultsPage.php @@ -18,15 +18,12 @@ class ProjectsResultsPage implements \JsonSerializable { * The source plugin's label. * @param string $pluginId * The source plugin's ID. - * @param bool $isPackageManagerRequired - * True if Package Manager is required. */ public function __construct( public readonly int $totalResults, public readonly array $list, public readonly string $pluginLabel, public readonly string $pluginId, - public readonly bool $isPackageManagerRequired, ) { assert(array_is_list($list)); } diff --git a/src/ProjectBrowserServiceProvider.php b/src/ProjectBrowserServiceProvider.php index 9977fcee83d79d1391c8897cfc8037147368897f..e9b8ddd675bb0a432a26e6259e176e4cadb62688 100644 --- a/src/ProjectBrowserServiceProvider.php +++ b/src/ProjectBrowserServiceProvider.php @@ -12,11 +12,13 @@ use Drupal\Core\Extension\ThemeExtensionList; use Drupal\Core\PrivateKey; use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Queue\QueueInterface; +use Drupal\Core\Recipe\Recipe; use Drupal\Core\State\StateInterface; use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; use Drupal\project_browser\ComposerInstaller\Installer; use Drupal\project_browser\ComposerInstaller\Validator\CoreNotUpdatedValidator; use Drupal\project_browser\ComposerInstaller\Validator\PackageNotInstalledValidator; +use Symfony\Component\DependencyInjection\Parameter; /** * Base class acts as a helper for Project Browser services. @@ -45,6 +47,15 @@ class ProjectBrowserServiceProvider extends ServiceProviderBase { ->setAutowired(TRUE); } + if (class_exists(Recipe::class)) { + $container->register(RecipeActivator::class, RecipeActivator::class) + ->setAutowired(TRUE) + ->setArgument('$appRoot', new Parameter('app.root')) + ->addTag('project_browser.activator') + // Because it's an event subscriber, the activator needs to be public. + ->addTag('event_subscriber'); + } + // @todo Remove the following Drupal 10.0 autowiring shim in // https://www.drupal.org/i/3349193. $autowire_aliases = [ diff --git a/src/ProjectType.php b/src/ProjectType.php index de1e22a7eb89b27886f8ea8f7bc48af112e3753e..85ebba500e9c9834abeb642fc6619e0fd0ae7d7b 100644 --- a/src/ProjectType.php +++ b/src/ProjectType.php @@ -7,13 +7,11 @@ namespace Drupal\project_browser; /** * The different project types known to Project Browser. * - * @todo Add a case for recipes once support for Drupal 10.2 and earlier is - * dropped. - * * @see \Drupal\project_browser\ProjectBrowser\Project */ enum ProjectType: string { case Module = 'module'; + case Recipe = 'recipe'; } diff --git a/src/RecipeActivator.php b/src/RecipeActivator.php new file mode 100644 index 0000000000000000000000000000000000000000..b3315f9e60dedf0be844e60b3c6fcc522b936204 --- /dev/null +++ b/src/RecipeActivator.php @@ -0,0 +1,140 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser; + +use Composer\InstalledVersions; +use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\File\FileUrlGeneratorInterface; +use Drupal\Core\Recipe\Recipe; +use Drupal\Core\Recipe\RecipeAppliedEvent; +use Drupal\Core\Recipe\RecipeRunner; +use Drupal\Core\State\StateInterface; +use Drupal\Core\Url; +use Drupal\project_browser\ProjectBrowser\Project; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Response; + +/** + * Applies locally installed recipes. + */ +class RecipeActivator implements ActivatorInterface, EventSubscriberInterface { + + use ActivationInstructionsTrait { + __construct as traitConstruct; + } + + /** + * The state key that stores the record of all applied recipes. + * + * @var string + */ + private const STATE_KEY = 'project_browser.applied_recipes'; + + public function __construct( + private readonly string $appRoot, + private readonly StateInterface $state, + private readonly FileSystemInterface $fileSystem, + ModuleExtensionList $moduleList, + FileUrlGeneratorInterface $fileUrlGenerator, + ) { + $this->traitConstruct($moduleList, $fileUrlGenerator); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + RecipeAppliedEvent::class => 'onApply', + ]; + } + + /** + * Reacts when a recipe is applied to the site. + * + * @param \Drupal\Core\Recipe\RecipeAppliedEvent $event + * The event object. + */ + public function onApply(RecipeAppliedEvent $event): void { + $list = $this->state->get(static::STATE_KEY, []); + $list[] = $event->recipe->path; + $this->state->set(static::STATE_KEY, $list); + } + + /** + * {@inheritdoc} + */ + public function getStatus(Project $project): ActivationStatus { + $path = $this->getPath($project); + + if (in_array($path, $this->state->get(static::STATE_KEY, []), TRUE)) { + return ActivationStatus::Active; + } + elseif ($project->packageName === 'drupal/core') { + // Recipes that are part of core are always present. + return ActivationStatus::Present; + } + else { + return is_string($path) ? ActivationStatus::Present : ActivationStatus::Absent; + } + } + + /** + * {@inheritdoc} + */ + public function supports(Project $project): bool { + // @see \Drupal\project_browser\Plugin\ProjectBrowserSource\Recipes + return $project->type === ProjectType::Recipe; + } + + /** + * {@inheritdoc} + */ + public function activate(Project $project): ?Response { + $recipe = Recipe::createFromDirectory($this->getPath($project)); + RecipeRunner::processRecipe($recipe); + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getInstructions(Project $project): string|Url|null { + $instructions = '<p>' . $this->t('To apply this recipe, run the following command at the command line:') . '</p>'; + + $command = sprintf( + 'cd %s && %s/php %s/core/scripts/drupal recipe %s', + $this->appRoot, + // cspell:ignore BINDIR + PHP_BINDIR, + $this->appRoot, + $this->getPath($project), + ); + $instructions .= $this->commandBox($command, 'apply'); + + return $instructions; + } + + /** + * Returns the absolute path of an installed recipe, if known. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * A project object with info about the recipe. + * + * @return string|null + * The absolute local path of the recipe, or NULL if it's not installed. + */ + private function getPath(Project $project): ?string { + if ($project->packageName === 'drupal/core') { + // The machine name is the directory name. + // @see \Drupal\project_browser\Plugin\ProjectBrowserSource\Recipes::getProjects() + return $this->appRoot . '/core/recipes/' . $project->machineName; + } + $path = InstalledVersions::getInstallPath($project->packageName); + return $path ? $this->fileSystem->realpath($path) : NULL; + } + +} diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js index 6558e541937bd38640dea02b3812b9ca23467174..d325379796e6afd1cf3e0143736c1e03c7299a40 100644 Binary files a/sveltejs/public/build/bundle.js and b/sveltejs/public/build/bundle.js differ diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map index 862e17e11551bcb04c6adf61a50ad58cfdbad3e6..7ac0ced3ac4b48a3ef9ad8f8e7e0ae72bbb438ba 100644 Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ diff --git a/sveltejs/src/Project/ActionButton.svelte b/sveltejs/src/Project/ActionButton.svelte index e8387ddc3eef3bd852c457af1d9e6441b79bbfe5..07a05da918d1f903494f0881baf1d2f8fba93bc1 100644 --- a/sveltejs/src/Project/ActionButton.svelte +++ b/sveltejs/src/Project/ActionButton.svelte @@ -1,7 +1,6 @@ <script> import { onMount } from 'svelte'; import { - MODULE_STATUS, ORIGIN_URL, ALLOW_UI_INSTALL, PM_VALIDATION_ERROR, @@ -18,40 +17,10 @@ let loading = false; let loadingPhase = 'Adding'; - const { drupalSettings, Drupal } = window; + const { Drupal } = window; - /** - * Determine is a project is present in the local Drupal codebase. - * - * @param {string} projectName - * The project name. - * @return {boolean} - * True if the project is present. - */ - function projectIsDownloaded(projectName) { - return ( - typeof drupalSettings !== 'undefined' && projectName in MODULE_STATUS - ); - } - - /** - * Determine if a project is installed in the local Drupal codebase. - * - * @param {string} projectName - * The project name. - * @return {boolean} - * True if the project is installed. - */ - function projectIsInstalled(projectName) { - return ( - typeof drupalSettings !== 'undefined' && - projectName in MODULE_STATUS && - MODULE_STATUS[projectName] === 1 - ); - } - - let projectInstalled = projectIsInstalled(project.project_machine_name); - let projectDownloaded = projectIsDownloaded(project.project_machine_name); + let projectInstalled = project.status === 'active'; + let projectDownloaded = project.status === 'present'; /** * Checks the download/install status of a project and updates the UI. diff --git a/sveltejs/src/Project/AddInstallButton.svelte b/sveltejs/src/Project/AddInstallButton.svelte index 5e2d5a6f256c4f3c3462c128ba8fbc9658c9fdfd..add2b0af9b8c9cd041d9e6b913d3eeda0fecd4da 100644 --- a/sveltejs/src/Project/AddInstallButton.svelte +++ b/sveltejs/src/Project/AddInstallButton.svelte @@ -1,8 +1,11 @@ <script> import { openPopup } from '../popup'; - import { MODULE_STATUS, ORIGIN_URL, PM_VALIDATION_ERROR } from '../constants'; + import { + ORIGIN_URL, + PM_VALIDATION_ERROR, + PACKAGE_MANAGER_AVAILABLE, + } from '../constants'; import ProjectButtonBase from './ProjectButtonBase.svelte'; - import { isPackageManagerRequired } from '../stores'; export let project; export let loading; @@ -58,7 +61,7 @@ /** * Installs an already downloaded module. */ - async function installModule() { + async function activateProject() { loading = true; const url = `${ORIGIN_URL}/admin/modules/project_browser/activate/${project.id}`; const installResponse = await fetch(url); @@ -75,7 +78,7 @@ handleError(installResponse); } if (responseContent.status === 0) { - MODULE_STATUS[project.project_machine_name] = 1; + project.status = 'active'; projectInstalled = true; loading = false; } @@ -87,7 +90,7 @@ * @param {boolean} install * If true, the module will be installed after it is downloaded. */ - function downloadModule(install = false) { + function downloadProject(install = false) { showStatus(true); /** @@ -136,14 +139,14 @@ // If this line is reached, then every stage of the download process // was completed without error and we can consider the module // downloaded and the process complete. - MODULE_STATUS[project.project_machine_name] = 0; + project.status = 'present'; projectDownloaded = true; loading = false; // If install is true, install the module before conveying the process // is complete to the UI. if (install === true) { - installModule(); + activateProject(); } } } @@ -156,12 +159,12 @@ <ProjectButtonBase click={() => { if (alreadyAdded) { - installModule(); + activateProject(); } else { - downloadModule(true); + downloadProject(true); } }} - disabled={PM_VALIDATION_ERROR && $isPackageManagerRequired} + disabled={PM_VALIDATION_ERROR && PACKAGE_MANAGER_AVAILABLE} > {alreadyAdded ? Drupal.t('Install') : Drupal.t('Add and Install')}<span class="visually-hidden">{project.title}</span diff --git a/sveltejs/src/ProjectBrowser.svelte b/sveltejs/src/ProjectBrowser.svelte index 5de0db2492acfb1f6eea8e9077124289c2a5efe6..4403c2e82742eaea922071e6346d86a233d636da 100644 --- a/sveltejs/src/ProjectBrowser.svelte +++ b/sveltejs/src/ProjectBrowser.svelte @@ -20,7 +20,6 @@ sortCriteria, preferredView, pageSize, - isPackageManagerRequired, } from './stores'; import MediaQuery from './MediaQuery.svelte'; import { @@ -32,10 +31,10 @@ ORIGIN_URL, FULL_MODULE_PATH, SORT_OPTIONS, - MODULE_STATUS, ALLOW_UI_INSTALL, PM_VALIDATION_ERROR, ACTIVE_PLUGINS, + PACKAGE_MANAGER_AVAILABLE, } from './constants'; // cspell:ignore tabwise @@ -116,13 +115,11 @@ dataArray = Object.values(data); rows = data[$activeTab].list; $rowsCount = data[$activeTab].totalResults; - $isPackageManagerRequired = data[$activeTab].isPackageManagerRequired; if ( - $isPackageManagerRequired && + PACKAGE_MANAGER_AVAILABLE && PM_VALIDATION_ERROR && typeof PM_VALIDATION_ERROR === 'string' && - MODULE_STATUS.package_manager && ALLOW_UI_INSTALL ) { const messenger = new Drupal.Message(); diff --git a/sveltejs/src/constants.js b/sveltejs/src/constants.js index 7f204cdd6651450b3b01c7034f0682cf510ee2dc..efc6460c8726de322c56fb1f876a21cff7ace8ea 100644 --- a/sveltejs/src/constants.js +++ b/sveltejs/src/constants.js @@ -15,7 +15,6 @@ export const DEFAULT_SOURCE_ID = export const CURRENT_SOURCES_KEYS = drupalSettings.project_browser.current_sources_keys; export const ORIGIN_URL = drupalSettings.project_browser.origin_url; -export const MODULE_STATUS = drupalSettings.project_browser.modules; export const FULL_MODULE_PATH = `${ORIGIN_URL}/${drupalSettings.project_browser.module_path}`; export const ALLOW_UI_INSTALL = drupalSettings.project_browser.ui_install; export const DARK_COLOR_SCHEME = @@ -23,3 +22,4 @@ export const DARK_COLOR_SCHEME = matchMedia('(prefers-color-scheme: dark)').matches; export const PM_VALIDATION_ERROR = drupalSettings.project_browser.pm_validation; export const ACTIVE_PLUGINS = drupalSettings.project_browser.active_plugins; +export const PACKAGE_MANAGER_AVAILABLE = drupalSettings.project_browser.package_manager_available; diff --git a/sveltejs/src/stores.js b/sveltejs/src/stores.js index fd76e78e6faaa6b6beb5842c236b237a36f65a42..46858cd0195f440367740bfd1ac9c27dc0aecf6f 100644 --- a/sveltejs/src/stores.js +++ b/sveltejs/src/stores.js @@ -82,8 +82,5 @@ const storedPageSize = JSON.parse(sessionStorage.getItem('pageSize')) || 12; export const pageSize = writable(storedPageSize); pageSize.subscribe((val) => sessionStorage.setItem('pageSize', JSON.stringify(val))); -// Store the Package Manager requirement. -export const isPackageManagerRequired = writable(false); - // Store the value of media queries. export const mediaQueryValues = writable(new Map()); diff --git a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php index 465501f2f055c390a348997e10c56f31f3ed495d..a4801e17d7fa6a9b4005ff3b76d02f87521a80a6 100644 --- a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php +++ b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php @@ -390,7 +390,6 @@ class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase { machineName: $machine_name, body: $body, title: $project['attributes']['title'], - status: $project['attributes']['status'], changed: strtotime($project['attributes']['changed']), created: strtotime($project['attributes']['created']), author: [ @@ -404,7 +403,7 @@ class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase { } } - return $this->createResultsPage($returned_list, TRUE, $api_response['total_results'] ?? 0); + return $this->createResultsPage($returned_list, $api_response['total_results'] ?? 0); } /** diff --git a/tests/modules/project_browser_test/src/TestActivator.php b/tests/modules/project_browser_test/src/TestActivator.php index 31c112021f58dc86afc0eb506062f5cd86d9ec35..9dae7ded6c2b00348e9b746fc046ddbda045ef5f 100644 --- a/tests/modules/project_browser_test/src/TestActivator.php +++ b/tests/modules/project_browser_test/src/TestActivator.php @@ -6,6 +6,7 @@ namespace Drupal\project_browser_test; use Drupal\Core\State\StateInterface; use Drupal\Core\Url; +use Drupal\project_browser\ActivationStatus; use Drupal\project_browser\ActivatorInterface; use Drupal\project_browser\ProjectBrowser\Project; use Symfony\Component\HttpFoundation\Response; @@ -24,14 +25,17 @@ class TestActivator implements ActivatorInterface { * {@inheritdoc} */ public function supports(Project $project): bool { - return TRUE; + return $this->decorated->supports($project); } /** * {@inheritdoc} */ - public function isActive(Project $project): bool { - return FALSE; + public function getStatus(Project $project): ActivationStatus { + if ($project->machineName === 'pinky_brain') { + return ActivationStatus::Present; + } + return $this->decorated->getStatus($project); } /** diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php index 466f888e4c2b0621f2d7fc171e5f921bf1d161ff..db60f34493cb3a55b4730cd13af69057e6d0b922 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Drupal\Tests\project_browser\FunctionalJavascript; +use Behat\Mink\Element\NodeElement; +use Drupal\Core\Recipe\Recipe; use Drupal\Core\State\StateInterface; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait; @@ -99,10 +101,11 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $popup = $assert_session->waitForElementVisible('css', '.project-browser-popup'); $this->assertNotEmpty($popup); // The Pinky and the Brain module doesn't actually exist in the filesystem, - // but it was registered with JavaScript as if it was to test the presence + // but the test activator pretends it does, in order to test the presence // of the "Install" button as opposed vs. the default "Add and Install" // button. This happens to be a good way to test mid-install exceptions as // well. + // @see \Drupal\project_browser_test\TestActivator::getStatus() $this->assertStringContainsString('MissingDependencyException: Unable to install modules pinky_brain due to missing modules pinky_brain', $popup->getText()); // The action button should have momentarily changed to a progress message, @@ -112,6 +115,39 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $this->assertSame('Install Pinky and the Brain', $action_button->getText()); } + /** + * Tests applying a recipe from the project browser UI. + */ + public function testApplyRecipe(): void { + if (!class_exists(Recipe::class)) { + $this->markTestSkipped('This test cannot run because this version of Drupal does not support recipes.'); + } + $assert_session = $this->assertSession(); + + $this->config('project_browser.admin_settings') + ->set('enabled_sources', ['recipes']) + ->save(); + + $this->drupalGet('admin/modules/browse'); + $this->svelteInitHelper('css', '.pb-projects-list'); + $this->inputSearchField('image'); + + // Apply a recipe that ships with core. + $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Image media type")'); + $this->assertNotEmpty($card); + $assert_session->buttonExists('Install', $card)->press(); + $recipe_applied = $card->waitFor(30, function (NodeElement $card): bool { + return $card->has('css', '.project_status-indicator:contains("Installed")'); + }); + $this->assertTrue($recipe_applied); + + // If we reload, the installation status should be remembered. + $this->getSession()->reload(); + $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Image media type")'); + $this->assertNotEmpty($card); + $assert_session->elementExists('css', '.project_status-indicator:contains("Installed")', $card); + } + /** * Tests install UI not available if not enabled. */ diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php index 23e55c875c7974775d2a0290be6b82e0ad29d00c..4064035b2a7be0115cb5b208fc3e35598856bd7f 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php @@ -6,6 +6,7 @@ namespace Drupal\Tests\project_browser\FunctionalJavascript; use Behat\Mink\Element\NodeElement; use Drupal\Core\Extension\MissingDependencyException; +use Drupal\Core\Recipe\Recipe; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; // cspell:ignore coverageall doomer eggman quiznos statusactive statusmaintained @@ -1018,4 +1019,35 @@ class ProjectBrowserUiTest extends WebDriverTestBase { $this->assertNotEquals('clear-text', $has_focus_id); } + /** + * Tests that recipes show instructions for applying them. + */ + public function testRecipeInstructions(): void { + if (!class_exists(Recipe::class)) { + $this->markTestSkipped('This test cannot run because this version of Drupal does not support recipes.'); + } + $assert_session = $this->assertSession(); + + $this->config('project_browser.admin_settings') + ->set('enabled_sources', ['recipes']) + ->save(); + + $this->drupalGet('admin/modules/browse'); + $this->svelteInitHelper('css', '.pb-projects-list'); + $this->inputSearchField('image'); + + // Look for a recipe that ships with core. + $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Image media type")'); + $this->assertNotEmpty($card); + $assert_session->buttonExists('View Commands', $card)->press(); + $input = $assert_session->waitForElementVisible('css', '.command-box input'); + $this->assertNotEmpty($input); + $command = $input->getValue(); + // A full path to the PHP executable should be in the command. + $this->assertMatchesRegularExpression('/[^\s]+\/php /', $command); + $drupal_root = $this->getDrupalRoot(); + $this->assertStringStartsWith("cd $drupal_root && ", $command); + $this->assertStringEndsWith("php $drupal_root/core/scripts/drupal recipe $drupal_root/core/recipes/image_media_type", $command); + } + } diff --git a/tests/src/Kernel/RecipesSourceTest.php b/tests/src/Kernel/RecipesSourceTest.php new file mode 100644 index 0000000000000000000000000000000000000000..bc1c723e003669161be803de8f9365c25332b15a --- /dev/null +++ b/tests/src/Kernel/RecipesSourceTest.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\project_browser\Kernel; + +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Recipe\Recipe; +use Drupal\KernelTests\KernelTestBase; +use Drupal\project_browser\EnabledSourceHandler; +use Drupal\project_browser\Plugin\ProjectBrowserSourceManager; +use Drupal\project_browser\ProjectType; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; + +/** + * Tests the source plugin that exposes locally installed recipes. + * + * @group project_browser + * @covers \Drupal\project_browser\Plugin\ProjectBrowserSource\Recipes + */ +class RecipesSourceTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['project_browser']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + if (!class_exists(Recipe::class)) { + $this->markTestSkipped('This test cannot be run because the recipe system is not available.'); + } + $this->installSchema('project_browser', [ + 'project_browser_projects', + 'project_browser_categories', + ]); + $this->installConfig('project_browser'); + } + + /** + * @covers \project_browser_install + * @covers \project_browser_project_browser_source_info_alter + */ + public function testRecipeSourceIsEnabledAtInstallTime(): void { + $this->assertNotContains('recipes', $this->config('project_browser.admin_settings')->get('enabled_sources')); + + $this->container->get(ModuleHandlerInterface::class) + ->loadInclude('project_browser', 'install'); + project_browser_install(); + $this->assertContains('recipes', $this->config('project_browser.admin_settings')->get('enabled_sources')); + + $enabled_sources = $this->container->get(EnabledSourceHandler::class) + ->getCurrentSources(); + $this->assertArrayHasKey('recipes', $enabled_sources); + } + + /** + * Tests that recipes are discovered by the plugin. + */ + public function testRecipesAreDiscovered(): void { + $finder = Finder::create() + ->in($this->getDrupalRoot() . '/core/recipes') + ->directories() + ->notName('example') + ->depth(0); + $expected_recipe_names = array_map(fn (SplFileInfo $dir) => $dir->getBasename(), iterator_to_array($finder)); + // This contributed recipe is one of our dev dependencies. + $expected_recipe_names[] = 'imagemagick-configuration'; + + /** @var \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage $projects */ + $projects = $this->container->get(ProjectBrowserSourceManager::class) + ->createInstance('recipes') + ->getProjects(); + $found_recipes = []; + foreach ($projects->list as $project) { + $this->assertNotEmpty($project->title); + $this->assertSame(ProjectType::Recipe, $project->type); + $found_recipes[$project->machineName] = $project; + } + $found_recipe_names = array_keys($found_recipes); + + // The `example` recipe (from core) should always be hidden. + $this->assertNotContains('example', $expected_recipe_names); + + sort($expected_recipe_names); + sort($found_recipe_names); + $this->assertSame($expected_recipe_names, $found_recipe_names); + + // Ensure the package names are properly resolved. + $this->assertSame('drupal/core', $found_recipes['standard']?->packageName); + $this->assertSame('kanopi/imagemagick-configuration', $found_recipes['imagemagick-configuration']?->packageName); + + // The core recipes should have descriptions, which should become the body + // text of the project. + $this->assertArrayHasKey('standard', $found_recipes); + // The need for reflection sucks, but there's no way to introspect the body + // on the backend. + $body = (new \ReflectionProperty($found_recipes['standard'], 'body')) + ->getValue($found_recipes['standard']); + $this->assertNotEmpty($body); + } + +}