diff --git a/phpstan.neon b/phpstan.neon index 76dde2088bba117a8d58282f01024deb0a8e92a3..48d0f50e7e870d64ea84a77f6ed66ab64f7af554 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -73,20 +73,20 @@ parameters: message: "#^Access to constant COMPOSER_PROJECT_TYPE on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe\\.$#" paths: - src/Plugin/ProjectBrowserSource/Recipes.php - - src/RecipeActivator.php + - src/Activator/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 + path: src/Activator/RecipeActivator.php reportUnmatched: false - message: "#^Class Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent not found\\.$#" - path: src/RecipeActivator.php + path: src/Activator/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 + message: "#^Parameter \\$event of method Drupal\\\\project_browser\\\\Activator\\\\RecipeActivator\\:\\:onApply\\(\\) has invalid type Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent\\.$#" + path: src/Activator/RecipeActivator.php reportUnmatched: false - diff --git a/project_browser.services.yml b/project_browser.services.yml index 53c79727684ad2d0755d098ba07610b2a535245e..aa19f8e83418255af608b33dc7c23c9cb9b50209 100644 --- a/project_browser.services.yml +++ b/project_browser.services.yml @@ -20,11 +20,10 @@ services: - { name: cache.bin } factory: cache_factory:get arguments: [project_browser] - Drupal\project_browser\Activator: + Drupal\project_browser\ActivationManager: tags: - { name: service_collector, tag: project_browser.activator, call: addActivator } - Drupal\project_browser\ActivatorInterface: '@Drupal\project_browser\Activator' - Drupal\project_browser\ModuleActivator: + Drupal\project_browser\Activator\ModuleActivator: public: false tags: - { name: project_browser.activator } diff --git a/src/ActivationManager.php b/src/ActivationManager.php new file mode 100644 index 0000000000000000000000000000000000000000..5c6bf932dd0fd69851b3858b570b2cada1855e11 --- /dev/null +++ b/src/ActivationManager.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser; + +use Drupal\Component\Utility\Xss; +use Drupal\project_browser\Activator\ActivationStatus; +use Drupal\project_browser\Activator\ActivatorInterface; +use Drupal\project_browser\Activator\InstructionsInterface; +use Drupal\project_browser\ProjectBrowser\Project; +use Symfony\Component\HttpFoundation\Response; + +/** + * A generalized activator that can handle any type of project. + * + * This is a service collector that tries to delegate to the first registered + * activator that says it supports a given project. + */ +final class ActivationManager { + + /** + * The registered activators. + * + * @var \Drupal\project_browser\Activator\ActivatorInterface[] + */ + private array $activators = []; + + /** + * Registers an activator. + * + * @param \Drupal\project_browser\Activator\ActivatorInterface $activator + * The activator to register. + */ + public function addActivator(ActivatorInterface $activator): void { + if (in_array($activator, $this->activators, TRUE)) { + return; + } + $this->activators[] = $activator; + } + + /** + * Determines if a particular project is activated on the current site. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * A project to check. + * + * @return \Drupal\project_browser\Activator\ActivationStatus + * The state of the project on the current site. + */ + public function getStatus(Project $project): ActivationStatus { + return $this->getActivatorForProject($project)->getStatus($project); + } + + /** + * Returns the registered activator to handle a given project. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * A project object. + * + * @return \Drupal\project_browser\Activator\ActivatorInterface + * The activator which can handle the given project. + * + * @throws \InvalidArgumentException + * Thrown if none of the registered activators can handle the given project. + */ + private function getActivatorForProject(Project $project): ActivatorInterface { + foreach ($this->activators as $activator) { + if ($activator->supports($project)) { + return $activator; + } + } + throw new \InvalidArgumentException("The project '$project->machineName' is not supported by any registered activators."); + } + + /** + * Gets activation information for a project, for delivery to the front-end. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * A project object. + * + * @return array + * An array of activation information. Will consist of: + * - `status`: The activation status of the project on the current site. + * Will be the lowercase name of the one of the cases of + * \Drupal\project_browser\Activator\ActivationStatus. + * - `commands`: The instructions a human can take to activate the project + * manually, or a URL where they can do so. Will be NULL if the registered + * activator which supports the given project is not capable of generating + * instructions. + * + * @see \Drupal\project_browser\ProjectBrowser\Project::toArray() + */ + public function getActivationInfo(Project $project): array { + $data = []; + + $activator = $this->getActivatorForProject($project); + $data['status'] = strtolower($activator->getStatus($project)->name); + + if ($activator instanceof InstructionsInterface) { + $data['commands'] = Xss::filter( + $activator->getInstructions($project), + [...Xss::getAdminTagList(), 'textarea', 'button'], + ); + } + else { + $data['commands'] = NULL; + } + + return $data; + } + + /** + * Activates a project on the current site. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * The project to activate. + * + * @return \Symfony\Component\HttpFoundation\Response|null + * The response, or lack thereof, returned by the first registered activator + * that supports the given project. + */ + public function activate(Project $project): ?Response { + return $this->getActivatorForProject($project)->activate($project); + } + +} diff --git a/src/Activator.php b/src/Activator.php deleted file mode 100644 index 0e363bf816a19a8c3f9768dfdb88d1c8d5532246..0000000000000000000000000000000000000000 --- a/src/Activator.php +++ /dev/null @@ -1,94 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\project_browser; - -use Drupal\Core\Url; -use Drupal\project_browser\ProjectBrowser\Project; -use Symfony\Component\HttpFoundation\Response; - -/** - * A generalized activator that can handle any type of project. - * - * This is a service collector that tries to delegate to the first registered - * activator that says it supports a given project. - */ -final class Activator implements ActivatorInterface { - - /** - * The registered activators. - * - * @var \Drupal\project_browser\ActivatorInterface[] - */ - private array $activators = []; - - /** - * Registers an activator. - * - * @param \Drupal\project_browser\ActivatorInterface $activator - * The activator to register. - */ - public function addActivator(ActivatorInterface $activator): void { - if (in_array($activator, $this->activators, TRUE)) { - return; - } - $this->activators[] = $activator; - } - - /** - * Returns the registered activator to handle a given project. - * - * @param \Drupal\project_browser\ProjectBrowser\Project $project - * A project object. - * - * @return \Drupal\project_browser\ActivatorInterface - * The activator which can handle the given project. - * - * @throws \InvalidArgumentException - * Thrown if none of the registered activators can handle the given project. - */ - private function getActivatorForProject(Project $project): ActivatorInterface { - foreach ($this->activators as $activator) { - if ($activator->supports($project)) { - return $activator; - } - } - throw new \InvalidArgumentException("The project '$project->machineName' is not supported by any registered activators."); - } - - /** - * {@inheritdoc} - */ - public function getStatus(Project $project): ActivationStatus { - return $this->getActivatorForProject($project)->getStatus($project); - } - - /** - * {@inheritdoc} - */ - public function supports(Project $project): bool { - try { - $this->getActivatorForProject($project); - return TRUE; - } - catch (\InvalidArgumentException) { - return FALSE; - } - } - - /** - * {@inheritdoc} - */ - public function activate(Project $project): ?Response { - return $this->getActivatorForProject($project)->activate($project); - } - - /** - * {@inheritdoc} - */ - public function getInstructions(Project $project): string|Url|null { - return $this->getActivatorForProject($project)->getInstructions($project); - } - -} diff --git a/src/ActivationStatus.php b/src/Activator/ActivationStatus.php similarity index 88% rename from src/ActivationStatus.php rename to src/Activator/ActivationStatus.php index 12ba43a43a894bac66fe3a9ebec196b8aa5dd86c..81c786a19d2bf489c1cc2d03f23437e00b80ae50 100644 --- a/src/ActivationStatus.php +++ b/src/Activator/ActivationStatus.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\project_browser; +namespace Drupal\project_browser\Activator; /** * Defines the possible states of a project in the current site. diff --git a/src/ActivatorInterface.php b/src/Activator/ActivatorInterface.php similarity index 67% rename from src/ActivatorInterface.php rename to src/Activator/ActivatorInterface.php index b174ae34b7a8950f4beb6019b3fa93feb5d5cfeb..5fc5a86395611680cb7f7450612972a359d71346 100644 --- a/src/ActivatorInterface.php +++ b/src/Activator/ActivatorInterface.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace Drupal\project_browser; +namespace Drupal\project_browser\Activator; -use Drupal\Core\Url; use Drupal\project_browser\ProjectBrowser\Project; use Symfony\Component\HttpFoundation\Response; @@ -23,7 +22,7 @@ interface ActivatorInterface { * @param \Drupal\project_browser\ProjectBrowser\Project $project * A project to check. * - * @return \Drupal\project_browser\ActivationStatus + * @return \Drupal\project_browser\Activator\ActivationStatus * The state of the project on the current site. */ public function getStatus(Project $project): ActivationStatus; @@ -56,21 +55,4 @@ interface ActivatorInterface { */ public function activate(Project $project): ?Response; - /** - * Returns instructions, if applicable, for how to activate a project. - * - * @param \Drupal\project_browser\ProjectBrowser\Project $project - * The project to activate. - * - * @return string|\Drupal\Core\Url|null - * One of: - * - A translated string containing human-readable instructions for how to - * activate the given project. The UI will display these instructions in - * a modal dialog. - * - A URL which this project's "Install" button should link to in the UI. - * - NULL if instructions are unavailable or unnecessary (for example, if - * the project is a module that's already installed). - */ - public function getInstructions(Project $project): string|Url|null; - } diff --git a/src/Activator/InstructionsInterface.php b/src/Activator/InstructionsInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..6d10167b0430f58adfb851e72021841ee566f197 --- /dev/null +++ b/src/Activator/InstructionsInterface.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser\Activator; + +use Drupal\project_browser\ProjectBrowser\Project; + +/** + * An interface for activators that can generate activation instructions. + */ +interface InstructionsInterface extends ActivatorInterface { + + /** + * Returns instructions for how to activate a project. + * + * @param \Drupal\project_browser\ProjectBrowser\Project $project + * The project to activate. + * + * @return string + * One of: + * - A translated string containing human-readable instructions for how to + * activate the given project. The UI will display these instructions in + * a modal dialog. + * - An absolute URL which this project's "Install" button should link to in + * the UI. + */ + public function getInstructions(Project $project): string; + +} diff --git a/src/ActivationInstructionsTrait.php b/src/Activator/InstructionsTrait.php similarity index 96% rename from src/ActivationInstructionsTrait.php rename to src/Activator/InstructionsTrait.php index ac5a57582b98380a92a68be91713af7724f2e16e..2deb87e303c02eb0a07df55baf31e3f17297f2c6 100644 --- a/src/ActivationInstructionsTrait.php +++ b/src/Activator/InstructionsTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\project_browser; +namespace Drupal\project_browser\Activator; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\File\FileUrlGeneratorInterface; @@ -12,7 +12,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup; /** * Provides helper methods for activators which generate instructions. */ -trait ActivationInstructionsTrait { +trait InstructionsTrait { use StringTranslationTrait; diff --git a/src/ModuleActivator.php b/src/Activator/ModuleActivator.php similarity index 89% rename from src/ModuleActivator.php rename to src/Activator/ModuleActivator.php index f329fe3ab0a74f00181ad3be4b0e10ff177b813d..26a4a5e72246c53c0b95efe8e9772b32d12d6307 100644 --- a/src/ModuleActivator.php +++ b/src/Activator/ModuleActivator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\project_browser; +namespace Drupal\project_browser\Activator; use Composer\InstalledVersions; use Drupal\Core\Extension\ModuleExtensionList; @@ -10,14 +10,15 @@ use Drupal\Core\Extension\ModuleInstallerInterface; use Drupal\Core\File\FileUrlGeneratorInterface; use Drupal\Core\Url; use Drupal\project_browser\ProjectBrowser\Project; +use Drupal\project_browser\ProjectType; use Symfony\Component\HttpFoundation\Response; /** * An activator for Drupal modules. */ -final class ModuleActivator implements ActivatorInterface { +final class ModuleActivator implements InstructionsInterface { - use ActivationInstructionsTrait; + use InstructionsTrait; public function __construct( private readonly ModuleInstallerInterface $moduleInstaller, @@ -57,11 +58,12 @@ final class ModuleActivator implements ActivatorInterface { /** * {@inheritdoc} */ - public function getInstructions(Project $project): string|Url { + public function getInstructions(Project $project): string { if ($this->getStatus($project) === ActivationStatus::Present) { - return Url::fromRoute('system.modules_list', options: [ - 'fragment' => 'module-' . str_replace('_', '-', $project->machineName), - ]); + return Url::fromRoute('system.modules_list') + ->setOption('fragment', 'module-' . str_replace('_', '-', $project->machineName)) + ->setAbsolute() + ->toString(); } $commands = '<h3>' . $this->t('1. Download') . '</h3>'; diff --git a/src/RecipeActivator.php b/src/Activator/RecipeActivator.php similarity index 93% rename from src/RecipeActivator.php rename to src/Activator/RecipeActivator.php index 72df6d4afc7dab83bb16425fd59e9235d0c9d64e..9198df13cbfaba60f20cb76ae61c845829c5d0a8 100644 --- a/src/RecipeActivator.php +++ b/src/Activator/RecipeActivator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\project_browser; +namespace Drupal\project_browser\Activator; use Composer\InstalledVersions; use Drupal\Core\Extension\ModuleExtensionList; @@ -15,6 +15,7 @@ use Drupal\Core\Recipe\RecipeRunner; use Drupal\Core\State\StateInterface; use Drupal\Core\Url; use Drupal\project_browser\ProjectBrowser\Project; +use Drupal\project_browser\ProjectType; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -22,9 +23,9 @@ use Symfony\Component\HttpFoundation\Response; /** * Applies locally installed recipes. */ -final class RecipeActivator implements ActivatorInterface, EventSubscriberInterface { +final class RecipeActivator implements InstructionsInterface, EventSubscriberInterface { - use ActivationInstructionsTrait; + use InstructionsTrait; /** * The state key that stores the record of all applied recipes. @@ -115,7 +116,7 @@ final class RecipeActivator implements ActivatorInterface, EventSubscriberInterf // The `redirect` key is not meaningful to JsonResponse; this is handled // specially by the Svelte app. - // @see sveltejs/src/ProcessQueueButton.svelte + // @see sveltejs/src/ProcessInstallListButton.svelte return new JsonResponse([ 'redirect' => $url->setAbsolute()->toString(), ]); @@ -166,7 +167,7 @@ final class RecipeActivator implements ActivatorInterface, EventSubscriberInterf // If this is a test recipe, its package name will have a specific // prefix. if (str_starts_with($project->packageName, 'project-browser-test/')) { - $path = __DIR__ . '/../tests/fixtures/' . $project->machineName; + $path = $this->moduleList->getPath('project_browser') . '/tests/fixtures/' . $project->machineName; } else { // The package isn't installed, so we can't get the path. diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php index 2682848b20e0419c826d5250d6337872f96da701..1e717e4ed89543b4b61de34396c966e0058006f1 100644 --- a/src/Controller/InstallerController.php +++ b/src/Controller/InstallerController.php @@ -9,7 +9,7 @@ use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Url; use Drupal\package_manager\Exception\StageException; use Drupal\package_manager\StatusCheckTrait; -use Drupal\project_browser\ActivatorInterface; +use Drupal\project_browser\ActivationManager; use Drupal\project_browser\ComposerInstaller\Installer; use Drupal\project_browser\EnabledSourceHandler; use Drupal\project_browser\InstallState; @@ -41,7 +41,7 @@ final class InstallerController extends ControllerBase { private readonly EnabledSourceHandler $enabledSourceHandler, private readonly TimeInterface $time, private readonly LoggerInterface $logger, - private readonly ActivatorInterface $activator, + private readonly ActivationManager $activationManager, private readonly InstallState $installState, private readonly EventDispatcherInterface $eventDispatcher, ) {} @@ -59,7 +59,7 @@ final class InstallerController extends ControllerBase { $container->get(EnabledSourceHandler::class), $container->get(TimeInterface::class), $container->get('logger.channel.project_browser'), - $container->get(ActivatorInterface::class), + $container->get(ActivationManager::class), $container->get(InstallState::class), $container->get(EventDispatcherInterface::class), ); @@ -413,7 +413,7 @@ final class InstallerController extends ControllerBase { $this->installState->setState($project_id, 'activating'); try { $project = $this->enabledSourceHandler->getStoredProject($project_id); - $response = $this->activator->activate($project); + $response = $this->activationManager->activate($project); $this->installState->setState($project_id, 'installed'); } catch (\Throwable $e) { diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php index 5b1c4299cf284b0ab3af6fb2e1687829fc189806..c5e58fb2f5f93f52bebc249a9fc60afaf0d6aa34 100644 --- a/src/Controller/ProjectBrowserEndpointController.php +++ b/src/Controller/ProjectBrowserEndpointController.php @@ -3,8 +3,10 @@ namespace Drupal\project_browser\Controller; use Drupal\Core\Controller\ControllerBase; -use Drupal\project_browser\ActivatorInterface; +use Drupal\project_browser\ActivationManager; use Drupal\project_browser\EnabledSourceHandler; +use Drupal\project_browser\ProjectBrowser\Project; +use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -17,7 +19,7 @@ final class ProjectBrowserEndpointController extends ControllerBase { public function __construct( private readonly EnabledSourceHandler $enabledSource, - private readonly ActivatorInterface $activator, + private readonly ActivationManager $activationManager, ) {} /** @@ -26,7 +28,7 @@ final class ProjectBrowserEndpointController extends ControllerBase { public static function create(ContainerInterface $container): static { return new static( $container->get(EnabledSourceHandler::class), - $container->get(ActivatorInterface::class), + $container->get(ActivationManager::class), ); } @@ -48,15 +50,34 @@ final class ProjectBrowserEndpointController extends ControllerBase { return new JsonResponse([], Response::HTTP_ACCEPTED); } - // The activator is the source of truth about the status of the project with - // respect to the current site, and it is responsible for generating - // the activation instructions or commands. - $result = $this->enabledSource->getProjects($query['source'], $query); - foreach ($result->list as $project) { - $project->status = $this->activator->getStatus($project); - $project->commands = $this->activator->getInstructions($project); - } - return new JsonResponse($result); + $results = $this->enabledSource->getProjects($query['source'], $query); + return new JsonResponse($this->prepareResults($results)); + } + + /** + * Prepares a set of results to be delivered to the front end. + * + * @param \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage $results + * A page of query results. + * + * @return array + * The query results, with activation info and fully qualified IDs added to + * all projects. + */ + private function prepareResults(ProjectsResultsPage $results): array { + $data = $results->toArray(); + + // Add activation info to all the projects in the result set, and fully + // qualify the project IDs by prefixing them with the source plugin ID. + $mapper = function (Project $project) use ($results): array { + $data = $this->activationManager->getActivationInfo($project) + $project->toArray(); + // Always send a fully qualified project ID to the front end. + $data['id'] = $results->pluginId . '/' . $project->id; + return $data; + }; + $data['list'] = array_map($mapper, $data['list']); + + return $data; } /** diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php index 46b6b93461e197a7489d852a6b24f39d20ad1a1d..1e27703d4cae65a70b601871e780510fd5918688 100644 --- a/src/ProjectBrowser/Project.php +++ b/src/ProjectBrowser/Project.php @@ -5,16 +5,14 @@ namespace Drupal\project_browser\ProjectBrowser; use Drupal\Component\Assertion\Inspector; use Drupal\Component\Utility\Html; use Drupal\Component\Utility\Unicode; -use Drupal\Component\Utility\Xss; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Url; -use Drupal\project_browser\ActivationStatus; use Drupal\project_browser\ProjectType; /** * Defines a single Project. */ -class Project implements \JsonSerializable { +class Project { /** * A persistent ID for this project in non-volatile storage. @@ -23,26 +21,6 @@ class Project implements \JsonSerializable { */ public readonly string $id; - /** - * The status of this project in the current site. - * - * This property is internal and should be ignored by source plugins. - * - * @var \Drupal\project_browser\ActivationStatus - */ - public ActivationStatus $status; - - /** - * The instructions, if any, to activate this project. - * - * This property is internal and should be ignored by source plugins. - * - * @var string|\Drupal\Core\Url|null - * - * @see \Drupal\project_browser\ActivatorInterface::getInstructions() - */ - public string|Url|null $commands = NULL; - /** * The project type (e.g., module, theme, recipe, or something else). * @@ -158,27 +136,12 @@ class Project implements \JsonSerializable { } /** - * Returns the selector id of the project. + * Returns a JSON-serializable array representation of this object. * - * @return string - * Selector id of the project. + * @return array + * This project, represented as a JSON-serializable array. */ - public function getSelectorId(): string { - return str_replace('_', '-', $this->machineName); - } - - /** - * {@inheritdoc} - */ - public function jsonSerialize(): array { - $commands = $this->commands; - if ($commands instanceof Url) { - $commands = $commands->setAbsolute()->toString(); - } - elseif (is_string($commands)) { - $commands = Xss::filter($commands, [...Xss::getAdminTagList(), 'textarea', 'button']); - } - + public function toArray(): array { if ($this->logo) { $logo = [ 'file' => $this->logo->setAbsolute()->toString(), @@ -209,13 +172,6 @@ class Project implements \JsonSerializable { 'package_name' => $this->packageName, 'is_maintained' => $this->isMaintained, 'url' => $this->url?->setAbsolute()->toString(), - 'status' => match ($this->status) { - ActivationStatus::Absent => 'absent', - ActivationStatus::Present => 'present', - ActivationStatus::Active => 'active', - }, - 'selector_id' => $this->getSelectorId(), - 'commands' => $commands, 'id' => $this->id, ]; } diff --git a/src/ProjectBrowser/ProjectsResultsPage.php b/src/ProjectBrowser/ProjectsResultsPage.php index 7e89a60d5118a6abab3c24f1dd33f4e54278aa4e..cf8ac24d3c0cac49ba1cd548255cabf5ec758a74 100644 --- a/src/ProjectBrowser/ProjectsResultsPage.php +++ b/src/ProjectBrowser/ProjectsResultsPage.php @@ -7,7 +7,7 @@ use Drupal\Component\Assertion\Inspector; /** * One page of search results from a query. */ -class ProjectsResultsPage implements \JsonSerializable { +class ProjectsResultsPage { /** * Constructor for project browser results page. @@ -20,7 +20,7 @@ class ProjectsResultsPage implements \JsonSerializable { * The source plugin's label. * @param string $pluginId * The source plugin's ID. - * @param string $error + * @param string|null $error * (optional) Error to pass along, if any. */ public function __construct( @@ -35,19 +35,13 @@ class ProjectsResultsPage implements \JsonSerializable { } /** - * {@inheritdoc} + * Returns the contents of this object as an array. + * + * @return array + * The contents of this object, as an array. */ - public function jsonSerialize(): array { - // Fully qualify the project IDs before sending them to the front end. - $map = function (Project $project): array { - return [ - 'id' => $this->pluginId . '/' . $project->id, - ] + $project->jsonSerialize(); - }; - - return [ - 'list' => array_map($map, $this->list), - ] + get_object_vars($this); + public function toArray(): array { + return get_object_vars($this); } } diff --git a/src/ProjectBrowserServiceProvider.php b/src/ProjectBrowserServiceProvider.php index 1dcd23e1d11eff1b86d7a8d5da97ccabec0fa513..c833a3c1fb5138cb47f4ca3d0bfdf790fbb4e641 100644 --- a/src/ProjectBrowserServiceProvider.php +++ b/src/ProjectBrowserServiceProvider.php @@ -15,6 +15,7 @@ use Drupal\Core\Queue\QueueInterface; use Drupal\Core\Recipe\Recipe; use Drupal\Core\State\StateInterface; use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; +use Drupal\project_browser\Activator\RecipeActivator; use Drupal\project_browser\ComposerInstaller\Installer; use Drupal\project_browser\ComposerInstaller\Validator\CoreNotUpdatedValidator; use Drupal\project_browser\ComposerInstaller\Validator\PackageNotInstalledValidator; diff --git a/tests/modules/project_browser_test/project_browser_test.services.yml b/tests/modules/project_browser_test/project_browser_test.services.yml index 5656c28daa3457edcd294fd28336dd471e92adb7..0b6118d0c128908300561b037ce95acec768d642 100644 --- a/tests/modules/project_browser_test/project_browser_test.services.yml +++ b/tests/modules/project_browser_test/project_browser_test.services.yml @@ -11,9 +11,8 @@ services: Drupal\project_browser_test\TestActivator: autowire: true public: false - decorates: 'Drupal\project_browser\ActivatorInterface' - arguments: - - '@.inner' + tags: + - { name: project_browser.activator, priority: 1000 } Drupal\project_browser_test\Extension\TestModuleInstaller: decorates: 'module_installer' public: false diff --git a/tests/modules/project_browser_test/src/TestActivator.php b/tests/modules/project_browser_test/src/TestActivator.php index 4cebb7ef6e2c0f3a8de97730a867b7a113b554a3..f10a5e9cde147c10e39c5550de02c1c643598af2 100644 --- a/tests/modules/project_browser_test/src/TestActivator.php +++ b/tests/modules/project_browser_test/src/TestActivator.php @@ -5,11 +5,9 @@ declare(strict_types=1); 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\Activator\ActivationStatus; +use Drupal\project_browser\Activator\ActivatorInterface; use Drupal\project_browser\ProjectBrowser\Project; -use Symfony\Component\HttpFoundation\Response; /** * A test activator that simply logs a state message. @@ -17,15 +15,25 @@ use Symfony\Component\HttpFoundation\Response; class TestActivator implements ActivatorInterface { public function __construct( - private readonly ActivatorInterface $decorated, private readonly StateInterface $state, ) {} + /** + * Sets the projects which will be handled this by activator. + * + * @param string ...$projects + * The Composer package names of the projects to handle. + */ + public static function handle(string ...$projects): void { + \Drupal::state()->set('test activator will handle', $projects); + } + /** * {@inheritdoc} */ public function supports(Project $project): bool { - return $this->decorated->supports($project); + $will_handle = $this->state->get('test activator will handle', []); + return in_array($project->packageName, $will_handle, TRUE); } /** @@ -35,24 +43,17 @@ class TestActivator implements ActivatorInterface { if ($project->machineName === 'pinky_brain') { return ActivationStatus::Present; } - return $this->decorated->getStatus($project); + return ActivationStatus::Absent; } /** * {@inheritdoc} */ - public function activate(Project $project): ?Response { + public function activate(Project $project): null { $log_message = $this->state->get("test activator", []); $log_message[] = "$project->title was activated!"; $this->state->set("test activator", $log_message); - return $this->decorated->activate($project); - } - - /** - * {@inheritdoc} - */ - public function getInstructions(Project $project): string|Url|null { - return $this->decorated->getInstructions($project); + return NULL; } } diff --git a/tests/src/Functional/EnabledSourceHandlerTest.php b/tests/src/Functional/EnabledSourceHandlerTest.php index 6942a41c71ad2ab497bfe0d4c79a96fceefc307d..f0b9ce8b49c9d5bb7f0fb04dd55e6b6e04ac86c9 100644 --- a/tests/src/Functional/EnabledSourceHandlerTest.php +++ b/tests/src/Functional/EnabledSourceHandlerTest.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\project_browser\Functional; -use Drupal\project_browser\ActivationStatus; use Drupal\project_browser\EnabledSourceHandler; -use Drupal\project_browser\ProjectBrowser\Project; use Drupal\project_browser_test\Plugin\ProjectBrowserSource\ProjectBrowserTestMock; use Drupal\Tests\BrowserTestBase; @@ -58,14 +56,7 @@ class EnabledSourceHandlerTest extends BrowserTestBase { $project_again = $handler->getStoredProject('project_browser_test_mock/' . $project->id); $this->assertNotSame($project, $project_again); - // Project::$status is a typed property and therefore must be initialized - // before it is accessed by jsonSerialize(). - $project->status = ActivationStatus::Active; - $project_again->status = ActivationStatus::Active; - $this->assertSame($project->jsonSerialize(), $project_again->jsonSerialize()); - - // The activation status and commands should be set. - $this->assertTrue(self::hasActivationData($project_again)); + $this->assertSame($project->toArray(), $project_again->toArray()); } /** @@ -91,22 +82,6 @@ class EnabledSourceHandlerTest extends BrowserTestBase { $this->assertFalse($storage->has($query_cache_key)); } - /** - * Checks if a project object is carrying activation data. - * - * @param \Drupal\project_browser\ProjectBrowser\Project $project - * The project object. - * - * @return bool - * TRUE if the project has its activation status and commands set, FALSE - * otherwise. - */ - private static function hasActivationData(Project $project): bool { - $status = new \ReflectionProperty(Project::class, 'status'); - $commands = new \ReflectionProperty(Project::class, 'commands'); - return $status->isInitialized($project) && $commands->isInitialized($project); - } - /** * Tests that the install profile is ignored by the drupal_core source. */ diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php index 2f2416272b5c29d9fd60c9723a75fe51ba9cf094..78cef31cc9e9a8289982d5687fc39a081c5baa2c 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php @@ -10,6 +10,7 @@ use Drupal\Core\State\StateInterface; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\project_browser\EnabledSourceHandler; use Drupal\project_browser\InstallState; +use Drupal\project_browser_test\TestActivator; use Drupal\system\SystemManager; use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait; @@ -76,6 +77,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { * Tests the "select" button functionality. */ public function testSingleModuleAddAndInstall(): void { + TestActivator::handle('drupal/cream_cheese'); + $assert_session = $this->assertSession(); $page = $this->getSession()->getPage(); $this->drupalGet('admin/modules/browse/project_browser_test_mock'); @@ -365,6 +368,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { * Tests the "Install selected projects" button functionality. */ public function testMultipleModuleAddAndInstall(): void { + TestActivator::handle('drupal/cream_cheese', 'drupal/kangaroo'); + $page = $this->getSession()->getPage(); $assert_session = $this->assertSession(); $this->drupalGet('project-browser/project_browser_test_mock'); diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php index d8b23783aefb92d437a25a1ae21da69d66c71ce6..805c0cb107860e01cd8b9f270c6f076b67b34e59 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php @@ -1038,7 +1038,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase { $href = $install_link?->getAttribute('href'); $this->assertIsString($href); - $this->assertStringContainsString('admin/modules#module-inline-form-errors', $href); + $this->assertStringEndsWith('/admin/modules#module-inline-form-errors', $href); $this->drupalGet($href); $assert_session->waitForElementVisible('css', "#edit-modules-inline-form-errors-enable"); $assert_session->assertVisibleInViewport('css', '#edit-modules-inline-form-errors-enable'); diff --git a/tests/src/Kernel/RecipeActivatorTest.php b/tests/src/Kernel/RecipeActivatorTest.php index cf14f1a57ebb7b61935e7c66bcf94555b9ffd0ea..bc9c12547c39187cb4d6d0b942d6096cb558f5c0 100644 --- a/tests/src/Kernel/RecipeActivatorTest.php +++ b/tests/src/Kernel/RecipeActivatorTest.php @@ -8,8 +8,8 @@ use Drupal\Core\Recipe\Recipe; use Drupal\Core\Recipe\RecipeRunner; use Drupal\Core\State\StateInterface; use Drupal\KernelTests\KernelTestBase; -use Drupal\project_browser\ActivationStatus; -use Drupal\project_browser\ActivatorInterface; +use Drupal\project_browser\ActivationManager; +use Drupal\project_browser\Activator\ActivationStatus; use Drupal\project_browser\ProjectBrowser\Project; use Drupal\project_browser\ProjectType; @@ -17,7 +17,7 @@ use Drupal\project_browser\ProjectType; * Tests the recipe activator. Obviously. * * @group project_browser - * @covers \Drupal\project_browser\RecipeActivator + * @covers \Drupal\project_browser\Activator\RecipeActivator */ class RecipeActivatorTest extends KernelTestBase { @@ -55,13 +55,13 @@ class RecipeActivatorTest extends KernelTestBase { packageName: 'My Project', type: ProjectType::Recipe, ); - /** @var \Drupal\project_browser\ActivatorInterface $activator */ - $activator = $this->container->get(ActivatorInterface::class); + /** @var \Drupal\project_browser\ActivationManager $activation_manager */ + $activation_manager = $this->container->get(ActivationManager::class); // As this project is not installed the RecipeActivator::getPath() will // return NULL in RecipeActivator::getStatus() and it will return the // status as Absent. - // @see \Drupal\project_browser\RecipeActivator::getStatus() - $this->assertSame(ActivationStatus::Absent, $activator->getStatus($project)); + // @see \Drupal\project_browser\Activator\RecipeActivator::getStatus() + $this->assertSame(ActivationStatus::Absent, $activation_manager->getStatus($project)); } }