diff --git a/project_browser.libraries.yml b/project_browser.libraries.yml index eb86d09ec2f62debf56126e79248c19886841e1f..1716ee1fdc00efacd3b257e75f4fb2def7498c55 100644 --- a/project_browser.libraries.yml +++ b/project_browser.libraries.yml @@ -8,6 +8,7 @@ svelte: dependencies: - core/drupalSettings - core/drupal + - core/drupal.ajax - core/drupal.debounce - core/drupal.dialog - core/drupal.announce diff --git a/project_browser.services.yml b/project_browser.services.yml index 4e2e42215233b7bccbd3109191c28ffd8e2ec63b..05c3bcd981ed75eb52ab062f91c3e1322e9866ec 100644 --- a/project_browser.services.yml +++ b/project_browser.services.yml @@ -41,3 +41,4 @@ services: public: false tags: - { name: paramconverter } + Drupal\project_browser\ProjectBrowser\Normalizer: ~ diff --git a/src/ActivationManager.php b/src/ActivationManager.php index 91e78e68dd08fd2d939bf85134c68a7d5a6bec87..b2a89d7895a667894548b1f8dc838838122800e4 100644 --- a/src/ActivationManager.php +++ b/src/ActivationManager.php @@ -4,15 +4,9 @@ declare(strict_types=1); namespace Drupal\project_browser; -use Drupal\Component\Utility\Xss; -use Drupal\Core\Link; -use Drupal\Core\Render\RendererInterface; use Drupal\project_browser\Activator\ActivationStatus; use Drupal\project_browser\Activator\ActivatorInterface; -use Drupal\project_browser\Activator\InstructionsInterface; -use Drupal\project_browser\Activator\TasksInterface; use Drupal\project_browser\ProjectBrowser\Project; -use Symfony\Component\HttpFoundation\Response; /** * A generalized activator that can handle any type of project. @@ -29,10 +23,6 @@ final class ActivationManager { */ private array $activators = []; - public function __construct( - private readonly RendererInterface $renderer, - ) {} - /** * Registers an activator. * @@ -71,7 +61,7 @@ final class ActivationManager { * @throws \InvalidArgumentException * Thrown if none of the registered activators can handle the given project. */ - private function getActivatorForProject(Project $project): ActivatorInterface { + public function getActivatorForProject(Project $project): ActivatorInterface { foreach ($this->activators as $activator) { if ($activator->supports($project)) { return $activator; @@ -80,71 +70,17 @@ final class ActivationManager { 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. - * - `tasks`: An array of \Drupal\Core\Link objects for specific follow-up - * tasks that a user can take after activating this project. For example, - * could include a link to a module's configuration form, or a dashboard - * provided by a recipe. - * - * @see \Drupal\project_browser\ProjectBrowser\Project::toArray() - */ - public function getActivationInfo(Project $project): array { - $activator = $this->getActivatorForProject($project); - $data = [ - 'status' => strtolower($activator->getStatus($project)->name), - 'commands' => NULL, - 'tasks' => [], - ]; - - if ($activator instanceof InstructionsInterface) { - $data['commands'] = Xss::filter( - $activator->getInstructions($project), - [...Xss::getAdminTagList(), 'textarea', 'button'], - ); - } - - if ($activator instanceof TasksInterface) { - $map = function (Link $link): array { - $text = $link->getText(); - if (is_array($text)) { - $text = $this->renderer->renderInIsolation($text); - } - return [ - 'text' => (string) $text, - 'url' => $link->getUrl()->setAbsolute()->toString(), - ]; - }; - $data['tasks'] = array_values(array_map($map, $activator->getTasks($project))); - } - - 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. + * @return \Drupal\Core\Ajax\CommandInterface[]|null + * The AJAX commands, or lack thereof, returned by the first registered + * activator that supports the given project. */ - public function activate(Project $project): ?Response { + public function activate(Project $project): ?array { return $this->getActivatorForProject($project)->activate($project); } diff --git a/src/Activator/ActivatorInterface.php b/src/Activator/ActivatorInterface.php index 5fc5a86395611680cb7f7450612972a359d71346..ea28361b70bee70e4b31d46e8240491516fb0331 100644 --- a/src/Activator/ActivatorInterface.php +++ b/src/Activator/ActivatorInterface.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\project_browser\Activator; use Drupal\project_browser\ProjectBrowser\Project; -use Symfony\Component\HttpFoundation\Response; /** * Defines an interface for services which can activate projects. @@ -48,11 +47,9 @@ interface ActivatorInterface { * @param \Drupal\project_browser\ProjectBrowser\Project $project * The project to activate. * - * @return \Symfony\Component\HttpFoundation\Response|null - * Optionally, a response that should be presented to the user in Project - * Browser. This could be a set of additional instructions to display in a - * modal, for example, or a redirect to a configuration form. + * @return \Drupal\Core\Ajax\CommandInterface[]|null + * Optionally, an array of AJAX commands to run on the front end. */ - public function activate(Project $project): ?Response; + public function activate(Project $project): ?array; } diff --git a/src/Activator/ModuleActivator.php b/src/Activator/ModuleActivator.php index 9389d71732680fe3e5fb45411b5685fcc8fcefd0..6edee6617b58d394e07810e2b5837a76335a011d 100644 --- a/src/Activator/ModuleActivator.php +++ b/src/Activator/ModuleActivator.php @@ -14,7 +14,6 @@ use Drupal\Core\Session\AccountInterface; use Drupal\Core\Url; use Drupal\project_browser\ProjectBrowser\Project; use Drupal\project_browser\ProjectType; -use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RequestStack; /** @@ -56,12 +55,9 @@ final class ModuleActivator implements InstructionsInterface, TasksInterface { /** * {@inheritdoc} */ - public function activate(Project $project): JsonResponse { + public function activate(Project $project): null { $this->moduleInstaller->install([$project->machineName]); - - return new JsonResponse([ - 'tasks' => $this->getTasks($project), - ]); + return NULL; } /** diff --git a/src/Activator/RecipeActivator.php b/src/Activator/RecipeActivator.php index 5af9c6e778c65e03cd3e558626241c26036118e3..2cff5fc002cbf64b84585f84d386d91cf8a5f2c4 100644 --- a/src/Activator/RecipeActivator.php +++ b/src/Activator/RecipeActivator.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\project_browser\Activator; use Composer\InstalledVersions; +use Drupal\Core\Ajax\RedirectCommand; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\File\FileUrlGeneratorInterface; @@ -18,8 +19,6 @@ 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; /** * Applies locally installed recipes. @@ -93,7 +92,7 @@ final class RecipeActivator implements InstructionsInterface, EventSubscriberInt /** * {@inheritdoc} */ - public function activate(Project $project): ?Response { + public function activate(Project $project): ?array { $path = $this->getPath($project); if (!$path) { return NULL; @@ -114,19 +113,13 @@ final class RecipeActivator implements InstructionsInterface, EventSubscriberInt 'recipe' => $path, ], ]); - - // The `redirect` key is not meaningful to JsonResponse; this is handled - // specially by the Svelte app. - // @see sveltejs/src/ProcessInstallListButton.svelte - return new JsonResponse([ - 'redirect' => $url->setAbsolute()->toString(), - ]); + return [ + new RedirectCommand($url->setAbsolute()->toString()), + ]; } RecipeRunner::processRecipe($recipe); - return new JsonResponse([ - 'tasks' => $this->getTasks($project), - ]); + return NULL; } /** diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php index 29269ce895c8d07f76eca4e01d22f5e83042508e..0bd3f6b92f1d3dd8b8b1b8bb735ab2155bdadef3 100644 --- a/src/Controller/InstallerController.php +++ b/src/Controller/InstallerController.php @@ -4,21 +4,27 @@ namespace Drupal\project_browser\Controller; use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\MessageCommand; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Url; +use Drupal\Core\Utility\Error; use Drupal\package_manager\Exception\StageException; use Drupal\package_manager\StatusCheckTrait; use Drupal\project_browser\ActivationManager; use Drupal\project_browser\ComposerInstaller\Installer; use Drupal\project_browser\EnabledSourceHandler; use Drupal\project_browser\InstallState; +use Drupal\project_browser\ProjectBrowser\Normalizer; +use Drupal\project_browser\RefreshProjectCommand; use Drupal\system\SystemManager; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -43,6 +49,7 @@ final class InstallerController extends ControllerBase { private readonly ActivationManager $activationManager, private readonly InstallState $installState, private readonly EventDispatcherInterface $eventDispatcher, + private readonly NormalizerInterface $normalizer, ) {} /** @@ -61,6 +68,7 @@ final class InstallerController extends ControllerBase { $container->get(ActivationManager::class), $container->get(InstallState::class), $container->get(EventDispatcherInterface::class), + $container->get(Normalizer::class), ); } @@ -418,30 +426,53 @@ final class InstallerController extends ControllerBase { } /** - * Installs an already downloaded module. + * Installs an already downloaded project. * * @param \Symfony\Component\HttpFoundation\Request $request * The request. * - * @return \Symfony\Component\HttpFoundation\Response - * Status message. + * @return \Drupal\Core\Ajax\AjaxResponse + * A response that can be used by the client-side AJAX system. */ - public function activate(Request $request): Response { - foreach ($request->toArray() as $project_id) { + public function activate(Request $request): AjaxResponse { + $response = new AjaxResponse(); + + $projects = $request->getPayload()->get('projects') ?? []; + if ($projects) { + assert(is_string($projects)); + $projects = explode(',', $projects); + } + assert(is_array($projects)); + foreach ($projects as $project_id) { $this->installState->setState($project_id, 'activating'); + [$source_id] = explode('/', $project_id, 2); + try { $project = $this->enabledSourceHandler->getStoredProject($project_id); - $response = $this->activationManager->activate($project); + + $commands = $this->activationManager->activate($project); + foreach ($commands ?? [] as $command) { + $response->addCommand($command); + } $this->installState->setState($project_id, 'installed'); + + $project = $this->normalizer->normalize($project, context: ['source' => $source_id]); + assert(is_array($project)); + $response->addCommand(new RefreshProjectCommand($project)); } catch (\Throwable $e) { - return $this->errorResponse($e, 'project install'); - } - finally { - $this->installState->deleteAll(); + $message = $e->getMessage(); + $response->addCommand(new MessageCommand( + $message, + options: [ + 'type' => MessengerInterface::TYPE_ERROR, + ], + )); + Error::logException($this->logger, $e); } } - return $response ?? new JsonResponse(['status' => 0]); + $this->installState->deleteAll(); + return $response; } /** diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php index b75d3c938958e027076866d9c6efc369126e6b4f..05c23e0b66272ab4120ecf41b30fd4c294434c0b 100644 --- a/src/Controller/ProjectBrowserEndpointController.php +++ b/src/Controller/ProjectBrowserEndpointController.php @@ -4,16 +4,15 @@ namespace Drupal\project_browser\Controller; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Form\FormState; -use Drupal\project_browser\ActivationManager; use Drupal\project_browser\EnabledSourceHandler; -use Drupal\project_browser\ProjectBrowser\Project; -use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage; +use Drupal\project_browser\ProjectBrowser\Normalizer; use Drupal\system\Form\ModulesUninstallForm; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * Controller for the proxy layer. @@ -22,7 +21,7 @@ final class ProjectBrowserEndpointController extends ControllerBase { public function __construct( private readonly EnabledSourceHandler $enabledSource, - private readonly ActivationManager $activationManager, + private readonly NormalizerInterface $normalizer, ) {} /** @@ -31,7 +30,7 @@ final class ProjectBrowserEndpointController extends ControllerBase { public static function create(ContainerInterface $container): static { return new static( $container->get(EnabledSourceHandler::class), - $container->get(ActivationManager::class), + $container->get(Normalizer::class), ); } @@ -54,33 +53,7 @@ final class ProjectBrowserEndpointController extends ControllerBase { } $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; + return new JsonResponse($this->normalizer->normalize($results)); } /** diff --git a/src/ProjectBrowser/Normalizer.php b/src/ProjectBrowser/Normalizer.php new file mode 100644 index 0000000000000000000000000000000000000000..eafa7431ca514f038eb5073c823acaaf33442878 --- /dev/null +++ b/src/ProjectBrowser/Normalizer.php @@ -0,0 +1,117 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser\ProjectBrowser; + +use Drupal\Component\Utility\Xss; +use Drupal\Core\Link; +use Drupal\Core\Render\RendererInterface; +use Drupal\project_browser\ActivationManager; +use Drupal\project_browser\Activator\InstructionsInterface; +use Drupal\project_browser\Activator\TasksInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizes Project and ProjectsResultsPage objects into an array format. + */ +final class Normalizer implements NormalizerInterface { + + public function __construct( + private readonly ActivationManager $activationManager, + private readonly RendererInterface $renderer, + ) {} + + /** + * {@inheritdoc} + */ + public function normalize(mixed $data, ?string $format = NULL, array $context = []): array { + if ($data instanceof Project) { + assert(array_key_exists('source', $context)); + $data = $this->getActivationInfo($data) + $data->toArray(); + $data['id'] = $context['source'] . '/' . $data['id']; + } + elseif ($data instanceof ProjectsResultsPage) { + $context['source'] = $data->pluginId; + + $data = $data->toArray(); + $data['list'] = array_map( + fn (Project $project): array => $this->normalize($project, $format, $context), + $data['list'], + ); + } + return $data; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization(mixed $data, ?string $format = NULL, array $context = []): bool { + return $data instanceof Project || $data instanceof ProjectsResultsPage; + } + + /** + * {@inheritdoc} + */ + public function getSupportedTypes(?string $format): array { + return [ + Project::class => TRUE, + ProjectsResultsPage::class => TRUE, + ]; + } + + /** + * 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. + * - `tasks`: An array of \Drupal\Core\Link objects for specific follow-up + * tasks that a user can take after activating this project. For example, + * could include a link to a module's configuration form, or a dashboard + * provided by a recipe. + * + * @see \Drupal\project_browser\ProjectBrowser\Project::toArray() + */ + private function getActivationInfo(Project $project): array { + $activator = $this->activationManager->getActivatorForProject($project); + $data = [ + 'status' => strtolower($activator->getStatus($project)->name), + 'commands' => NULL, + 'tasks' => [], + ]; + + if ($activator instanceof InstructionsInterface) { + $data['commands'] = Xss::filter( + $activator->getInstructions($project), + [...Xss::getAdminTagList(), 'textarea', 'button'], + ); + } + + if ($activator instanceof TasksInterface) { + $map = function (Link $link): array { + $text = $link->getText(); + if (is_array($text)) { + $text = $this->renderer->renderInIsolation($text); + } + return [ + 'text' => (string) $text, + 'url' => $link->getUrl()->setAbsolute()->toString(), + ]; + }; + $data['tasks'] = array_values(array_map($map, $activator->getTasks($project))); + } + + return $data; + } + +} diff --git a/src/RefreshProjectCommand.php b/src/RefreshProjectCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..c6fab42a027e98558d6776fab416d2840b257364 --- /dev/null +++ b/src/RefreshProjectCommand.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\project_browser; + +use Drupal\Core\Ajax\CommandInterface; + +/** + * An AJAX command to refresh a particular project in the Svelte app. + */ +final class RefreshProjectCommand implements CommandInterface { + + public function __construct( + private readonly array $project, + ) {} + + /** + * {@inheritdoc} + */ + public function render(): array { + return [ + 'command' => 'refresh_project', + 'project' => $this->project, + ]; + } + +} diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js index 6ab303721ff78d20ecbe605d0f4438ff3090dafb..2dc635852ab161656e8e26b7c0bd72abc61f7084 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 bc7ad9184da97ca23d5fe3bd230bd982a766b9bb..b43efeff6ef60e0e41ec035178f8fc9cb8272d0c 100644 Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js index 2a89442dbf5db573c1f97db6ebb2415103d7319a..67025be165cf151ca9e1ccab804c04f2a5947e17 100644 --- a/sveltejs/src/InstallListProcessor.js +++ b/sveltejs/src/InstallListProcessor.js @@ -2,6 +2,8 @@ import { get, writable } from 'svelte/store'; import { openPopup } from './popup'; import { BASE_URL, CURRENT_PATH } from './constants'; +const { Drupal } = window; + export const updated = writable(0); // Store for the install list. @@ -95,30 +97,16 @@ export const handleError = async (errorResponse) => { * A promise that resolves when the project is activated. */ export const activateProject = async (projectIds) => { - const url = `${BASE_URL}admin/modules/project_browser/activate`; - - const installResponse = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + await new Drupal.Ajax( + null, + document.createElement('div'), + { + url: `${BASE_URL}admin/modules/project_browser/activate`, + submit: { + projects: projectIds.join(','), + }, }, - body: JSON.stringify(projectIds), - }); - - if (!installResponse.ok) { - await handleError(installResponse); - return; - } - - try { - const responseContent = JSON.parse(await installResponse.text()); - - if (responseContent.hasOwnProperty('redirect')) { - window.location.href = responseContent.redirect; - } - } catch (err) { - await handleError(installResponse); - } + ).execute(); }; /** diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php index 617fee9838f7713792c6e04d29d0bb498cecc5a7..68480eb72e0a9cabad73cd2e9411187292a585be 100644 --- a/tests/src/Functional/InstallerControllerTest.php +++ b/tests/src/Functional/InstallerControllerTest.php @@ -179,9 +179,10 @@ class InstallerControllerTest extends BrowserTestBase { // via composer. $contents = $this->drupalGet('admin/modules/project_browser/install-begin'); $this->stageId = Json::decode($contents)['stage_id']; - $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/core', [ - 'stage_id' => $this->stageId, - ]); + $response = $this->getPostResponse( + Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]), + ['project_browser_test_mock/core'], + ); $this->assertSame(500, (int) $response->getStatusCode()); $this->assertSame('{"message":"StageEventException: The following package is already installed: drupal\/core\n","phase":"require"}', (string) $response->getBody()); } @@ -206,9 +207,10 @@ class InstallerControllerTest extends BrowserTestBase { * @covers ::require */ private function doRequire(): void { - $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [ - 'stage_id' => $this->stageId, - ]); + $response = $this->getPostResponse( + Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]), + ['project_browser_test_mock/awesome_module'], + ); $expected_output = sprintf('{"phase":"require","status":0,"stage_id":"%s"}', $this->stageId); $this->assertSame($expected_output, (string) $response->getBody()); $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'requiring'); @@ -311,9 +313,10 @@ class InstallerControllerTest extends BrowserTestBase { $result = ValidationResult::createError([$message]); $this->doStart(); TestSubscriber::setTestResult([$result], PreRequireEvent::class); - $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [ - 'stage_id' => $this->stageId, - ]); + $response = $this->getPostResponse( + Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]), + ['project_browser_test_mock/awesome_module'], + ); $this->assertSame(500, (int) $response->getStatusCode()); $this->assertSame('{"message":"StageEventException: This is a PreRequire error.\n","phase":"require"}', (string) $response->getBody()); } @@ -327,9 +330,10 @@ class InstallerControllerTest extends BrowserTestBase { $error = new \Exception('PreRequire did not go well.'); TestSubscriber::setException($error, PreRequireEvent::class); $this->doStart(); - $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [ - 'stage_id' => $this->stageId, - ]); + $response = $this->getPostResponse( + Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]), + ['project_browser_test_mock/awesome_module'], + ); $this->assertSame(500, (int) $response->getStatusCode()); $this->assertSame('{"message":"StageEventException: PreRequire did not go well.","phase":"require"}', (string) $response->getBody()); } @@ -343,9 +347,10 @@ class InstallerControllerTest extends BrowserTestBase { $error = new \Exception('PostRequire did not go well.'); TestSubscriber::setException($error, PostRequireEvent::class); $this->doStart(); - $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [ - 'stage_id' => $this->stageId, - ]); + $response = $this->getPostResponse( + Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]), + ['project_browser_test_mock/awesome_module'], + ); $this->assertSame(500, (int) $response->getStatusCode()); $this->assertSame('{"message":"StageEventException: PostRequire did not go well.","phase":"require"}', (string) $response->getBody()); } @@ -561,11 +566,13 @@ class InstallerControllerTest extends BrowserTestBase { $assert_session->checkboxNotChecked('edit-modules-views-enable'); $assert_session->checkboxNotChecked('edit-modules-views-ui-enable'); - $response = $this->getPostResponse('project_browser.activate', 'drupal_core/views_ui'); + $response = $this->getPostResponse( + Url::fromRoute('project_browser.activate'), + ['projects' => 'drupal_core/views_ui'], + ); $this->assertSame(200, $response->getStatusCode()); $this->assertTrue(json_validate((string) $response->getBody())); - $this->rebuildContainer(); $this->drupalGet('admin/modules'); $assert_session->checkboxChecked('edit-modules-views-enable'); $assert_session->checkboxChecked('edit-modules-views-ui-enable'); @@ -589,28 +596,21 @@ class InstallerControllerTest extends BrowserTestBase { /** * Sends a POST request to the specified route with the provided project ID. * - * @param string $route_name - * The route to which the POST request is sent. - * @param string|string[] $project_id - * The project ID(s) to include in the POST request body. - * @param array $route_parameters - * (optional) An associative array of route parameters, such as 'stage_id', - * that will be included in the URL. + * @param \Drupal\Core\Url $url + * The URL to which the POST request is sent. + * @param array $payload + * The POST request body. Will be encoded to JSON. * * @return \Psr\Http\Message\ResponseInterface * The response. */ - private function getPostResponse(string $route_name, string|array $project_id, array $route_parameters = []): ResponseInterface { - $post_url = Url::fromRoute($route_name, $route_parameters); - - $request_options = [ + private function getPostResponse(Url $url, array $payload): ResponseInterface { + return $this->makeApiRequest('POST', $url, [ RequestOptions::HEADERS => [ 'Content-Type' => 'application/json', ], - ]; - $request_options[RequestOptions::BODY] = Json::encode((array) $project_id); - - return $this->makeApiRequest('POST', $post_url, $request_options); + RequestOptions::BODY => Json::encode($payload), + ]); } /** diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php index f0618297083d700c99aa1939dc61f946e42b0dc1..c29e717673d2b023cd96712cbabd598706794a63 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php @@ -96,14 +96,15 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $this->drupalGet('admin/modules/browse/project_browser_test_mock'); $this->waitForProject('Pinky and the Brain') ->pressButton('Install Pinky and the Brain'); - $popup = $this->assertElementIsVisible('css', '.project-browser-popup'); // The Pinky and the Brain module doesn't actually exist in the filesystem, // 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()); + $message = 'Unable to install modules pinky_brain due to missing modules pinky_brain'; + $this->assertPageHasText($message); + $this->assertSession()->statusMessageContains($message, 'error'); } /**