From efcfba6df1e029629276d0a0d61059ba8ce51f5c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 18:23:17 -0500
Subject: [PATCH 01/45] Sketch in the normalizer

---
 project_browser.services.yml                  |  1 +
 .../ProjectBrowserEndpointController.php      | 10 ++--
 src/ProjectBrowser/Normalizer.php             | 54 +++++++++++++++++++
 3 files changed, 61 insertions(+), 4 deletions(-)
 create mode 100644 src/ProjectBrowser/Normalizer.php

diff --git a/project_browser.services.yml b/project_browser.services.yml
index 4e2e42215..05c3bcd98 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/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index c5e58fb2f..0f13431e1 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -3,14 +3,15 @@
 namespace Drupal\project_browser\Controller;
 
 use Drupal\Core\Controller\ControllerBase;
-use Drupal\project_browser\ActivationManager;
 use Drupal\project_browser\EnabledSourceHandler;
+use Drupal\project_browser\ProjectBrowser\Normalizer;
 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;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
 
 /**
  * Controller for the proxy layer.
@@ -19,7 +20,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
 
   public function __construct(
     private readonly EnabledSourceHandler $enabledSource,
-    private readonly ActivationManager $activationManager,
+    private readonly NormalizerInterface $normalizer,
   ) {}
 
   /**
@@ -28,7 +29,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),
     );
   }
 
@@ -51,7 +52,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
     }
 
     $results = $this->enabledSource->getProjects($query['source'], $query);
-    return new JsonResponse($this->prepareResults($results));
+    return new JsonResponse($this->normalizer->normalize($results));
   }
 
   /**
@@ -65,6 +66,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
    *   all projects.
    */
   private function prepareResults(ProjectsResultsPage $results): array {
+    // @todo Move all of this to the normalizer.
     $data = $results->toArray();
 
     // Add activation info to all the projects in the result set, and fully
diff --git a/src/ProjectBrowser/Normalizer.php b/src/ProjectBrowser/Normalizer.php
new file mode 100644
index 000000000..f84c3a2b4
--- /dev/null
+++ b/src/ProjectBrowser/Normalizer.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\ProjectBrowser;
+
+use Drupal\project_browser\ActivationManager;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+final class Normalizer implements NormalizerInterface {
+
+  public function __construct(
+    private readonly ActivationManager $activationManager,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize(mixed $data, ?string $format = NULL, array $context = []): array {
+    if ($data instanceof Project) {
+      assert(array_key_exists('source', $context));
+      $data = $this->activationManager->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,
+    ];
+  }
+
+}
-- 
GitLab


From 11115eae3f625b36e6f67bd4c80cf6e9d217317d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 18:56:15 -0500
Subject: [PATCH 02/45] Remove prepareResults()

---
 .../ProjectBrowserEndpointController.php      | 29 -------------------
 1 file changed, 29 deletions(-)

diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index 0f13431e1..1c0dd781d 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -5,8 +5,6 @@ namespace Drupal\project_browser\Controller;
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\ProjectBrowser\Normalizer;
-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;
@@ -55,33 +53,6 @@ final class ProjectBrowserEndpointController extends ControllerBase {
     return new JsonResponse($this->normalizer->normalize($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 {
-    // @todo Move all of this to the normalizer.
-    $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;
-  }
-
   /**
    * Builds the query based on the current request.
    *
-- 
GitLab


From c7c50036d5f56a6f27d8ee79fadfc947f1bad2a4 Mon Sep 17 00:00:00 2001
From: narendra-drupal <87118318+narendra-drupal@users.noreply.github.com>
Date: Wed, 19 Feb 2025 16:02:46 +0530
Subject: [PATCH 03/45] getActivationInfo moved to Normalizer

---
 src/ActivationManager.php         | 65 +------------------------------
 src/ProjectBrowser/Normalizer.php | 65 ++++++++++++++++++++++++++++++-
 2 files changed, 65 insertions(+), 65 deletions(-)

diff --git a/src/ActivationManager.php b/src/ActivationManager.php
index 91e78e68d..53adf27d0 100644
--- a/src/ActivationManager.php
+++ b/src/ActivationManager.php
@@ -4,13 +4,8 @@ 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;
 
@@ -29,10 +24,6 @@ final class ActivationManager {
    */
   private array $activators = [];
 
-  public function __construct(
-    private readonly RendererInterface $renderer,
-  ) {}
-
   /**
    * Registers an activator.
    *
@@ -71,7 +62,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,60 +71,6 @@ 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.
    *
diff --git a/src/ProjectBrowser/Normalizer.php b/src/ProjectBrowser/Normalizer.php
index f84c3a2b4..eafa7431c 100644
--- a/src/ProjectBrowser/Normalizer.php
+++ b/src/ProjectBrowser/Normalizer.php
@@ -4,13 +4,22 @@ 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,
   ) {}
 
   /**
@@ -19,7 +28,7 @@ final class Normalizer implements NormalizerInterface {
   public function normalize(mixed $data, ?string $format = NULL, array $context = []): array {
     if ($data instanceof Project) {
       assert(array_key_exists('source', $context));
-      $data = $this->activationManager->getActivationInfo($data) + $data->toArray();
+      $data = $this->getActivationInfo($data) + $data->toArray();
       $data['id'] = $context['source'] . '/' . $data['id'];
     }
     elseif ($data instanceof ProjectsResultsPage) {
@@ -51,4 +60,58 @@ final class Normalizer implements NormalizerInterface {
     ];
   }
 
+  /**
+   * 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;
+  }
+
 }
-- 
GitLab


From ff90a1f468e245a0bd3e63ba50367ea5454ce17d Mon Sep 17 00:00:00 2001
From: narendra-drupal <87118318+narendra-drupal@users.noreply.github.com>
Date: Wed, 19 Feb 2025 16:59:44 +0530
Subject: [PATCH 04/45] return normalized projects in activate response

---
 src/Controller/InstallerController.php | 19 ++++++++++++-------
 1 file changed, 12 insertions(+), 7 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 63de17042..51fa14308 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -12,13 +12,14 @@ 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\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 +44,7 @@ final class InstallerController extends ControllerBase {
     private readonly ActivationManager $activationManager,
     private readonly InstallState $installState,
     private readonly EventDispatcherInterface $eventDispatcher,
+    private readonly NormalizerInterface $normalizer,
   ) {}
 
   /**
@@ -61,6 +63,7 @@ final class InstallerController extends ControllerBase {
       $container->get(ActivationManager::class),
       $container->get(InstallState::class),
       $container->get(EventDispatcherInterface::class),
+      $container->get(Normalizer::class),
     );
   }
 
@@ -392,21 +395,23 @@ 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 \Symfony\Component\HttpFoundation\JsonResponse
+   *   Returns normalized activated project data or an error message.
    */
-  public function activate(Request $request): Response {
+  public function activate(Request $request): JsonResponse {
+    $normalized_projects = [];
     foreach ($request->toArray() as $project_id) {
       $this->installState->setState($project_id, 'activating');
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
-        $response = $this->activationManager->activate($project);
+        $this->activationManager->activate($project);
         $this->installState->setState($project_id, 'installed');
+        $normalized_projects[] = $this->normalizer->normalize($project);
       }
       catch (\Throwable $e) {
         return $this->errorResponse($e, 'project install');
@@ -415,7 +420,7 @@ final class InstallerController extends ControllerBase {
         $this->installState->deleteAll();
       }
     }
-    return $response ?? new JsonResponse(['status' => 0]);
+    return new JsonResponse($normalized_projects);
   }
 
   /**
-- 
GitLab


From eeae74d973f3cc3704ebc736f03173132217897a Mon Sep 17 00:00:00 2001
From: narendra-drupal <87118318+narendra-drupal@users.noreply.github.com>
Date: Wed, 19 Feb 2025 17:47:45 +0530
Subject: [PATCH 05/45] Added source

---
 src/Controller/InstallerController.php | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 51fa14308..09c8419c7 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -409,9 +409,10 @@ final class InstallerController extends ControllerBase {
       $this->installState->setState($project_id, 'activating');
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
+        $plugin_id = strstr($project_id, '/', true);
         $this->activationManager->activate($project);
         $this->installState->setState($project_id, 'installed');
-        $normalized_projects[] = $this->normalizer->normalize($project);
+        $normalized_projects[] = $this->normalizer->normalize($project, 'json', ['source' => $plugin_id]);
       }
       catch (\Throwable $e) {
         return $this->errorResponse($e, 'project install');
-- 
GitLab


From b0bf4669a557cc11454359418717e7bba2d41048 Mon Sep 17 00:00:00 2001
From: narendra-drupal <87118318+narendra-drupal@users.noreply.github.com>
Date: Wed, 19 Feb 2025 17:53:10 +0530
Subject: [PATCH 06/45] PHPCS

---
 src/Controller/InstallerController.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 09c8419c7..9bd0b9f5d 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -409,7 +409,7 @@ final class InstallerController extends ControllerBase {
       $this->installState->setState($project_id, 'activating');
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
-        $plugin_id = strstr($project_id, '/', true);
+        $plugin_id = strstr($project_id, '/', TRUE);
         $this->activationManager->activate($project);
         $this->installState->setState($project_id, 'installed');
         $normalized_projects[] = $this->normalizer->normalize($project, 'json', ['source' => $plugin_id]);
-- 
GitLab


From 8b908709b4a8316d0f7bba6a54c0bc676b93e370 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 14:52:36 -0500
Subject: [PATCH 07/45] Make all activators return an array of AJAX commands

---
 src/ActivationManager.php            |  9 ++++-----
 src/Activator/ActivatorInterface.php |  9 +++------
 src/Activator/ModuleActivator.php    |  8 ++------
 src/Activator/RecipeActivator.php    | 19 ++++++-------------
 4 files changed, 15 insertions(+), 30 deletions(-)

diff --git a/src/ActivationManager.php b/src/ActivationManager.php
index 53adf27d0..b2a89d789 100644
--- a/src/ActivationManager.php
+++ b/src/ActivationManager.php
@@ -7,7 +7,6 @@ namespace Drupal\project_browser;
 use Drupal\project_browser\Activator\ActivationStatus;
 use Drupal\project_browser\Activator\ActivatorInterface;
 use Drupal\project_browser\ProjectBrowser\Project;
-use Symfony\Component\HttpFoundation\Response;
 
 /**
  * A generalized activator that can handle any type of project.
@@ -77,11 +76,11 @@ final class ActivationManager {
    * @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 5fc5a8639..ea28361b7 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 1b0d4727d..55ee2f4d3 100644
--- a/src/Activator/ModuleActivator.php
+++ b/src/Activator/ModuleActivator.php
@@ -13,7 +13,6 @@ use Drupal\Core\Link;
 use Drupal\Core\Url;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectType;
-use Symfony\Component\HttpFoundation\JsonResponse;
 
 /**
  * An activator for Drupal modules.
@@ -52,12 +51,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 9f65d171f..c0fe5f580 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;
   }
 
   /**
-- 
GitLab


From 76097e3e55b11d159f5e1e4b56e7064795d06f4b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 14:59:41 -0500
Subject: [PATCH 08/45] Make InstallerController deal with AJAX commands

---
 src/Controller/InstallerController.php | 31 +++++++++++++++++---------
 1 file changed, 21 insertions(+), 10 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 9bd0b9f5d..b84ac56fe 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -2,9 +2,14 @@
 
 namespace Drupal\project_browser\Controller;
 
+use Drupal\Component\Assertion\Inspector;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\CommandInterface;
+use Drupal\Core\Ajax\MessageCommand;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\Url;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\StatusCheckTrait;
@@ -401,27 +406,33 @@ final class InstallerController extends ControllerBase {
    *   The request.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   Returns normalized activated project data or an error message.
+   *   Return an AJAX response, or an error message.
    */
   public function activate(Request $request): JsonResponse {
-    $normalized_projects = [];
+    $response = new AjaxResponse();
     foreach ($request->toArray() as $project_id) {
       $this->installState->setState($project_id, 'activating');
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
-        $plugin_id = strstr($project_id, '/', TRUE);
-        $this->activationManager->activate($project);
+
+        $commands = $this->activationManager->activate($project);
+        if ($commands && !Inspector::assertAllObjects($commands, CommandInterface::class)) {
+          throw new \RuntimeException('Activators can only return \Drupal\Core\Ajax\CommandInterface objects, or NULL.');
+        }
+        array_walk($commands, $response->addCommand(...));
         $this->installState->setState($project_id, 'installed');
-        $normalized_projects[] = $this->normalizer->normalize($project, 'json', ['source' => $plugin_id]);
       }
       catch (\Throwable $e) {
-        return $this->errorResponse($e, 'project install');
-      }
-      finally {
-        $this->installState->deleteAll();
+        $response->addCommand(new MessageCommand(
+          $e->getMessage(),
+          options: [
+            'type' => MessengerInterface::TYPE_ERROR,
+          ],
+        ));
       }
     }
-    return new JsonResponse($normalized_projects);
+    $this->installState->deleteAll();
+    return $response;
   }
 
   /**
-- 
GitLab


From 24085764a424e561fcfddd80bbb9a78653fa644e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:02:29 -0500
Subject: [PATCH 09/45] Remove redirect handling from Svelte code

---
 sveltejs/public/build/bundle.js      | Bin 274199 -> 273910 bytes
 sveltejs/public/build/bundle.js.map  | Bin 253457 -> 252880 bytes
 sveltejs/src/InstallListProcessor.js |  10 ----------
 3 files changed, 10 deletions(-)

diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 1f08f0e940735a257cf0e693dc30fba071e420a7..1d86c1ef9b510c40196fb92b18c344b068c94780 100644
GIT binary patch
delta 30
lcmbP!PvF~afem~5C-e1+H-DMX{$&Cq5HoH6GJ#on9svIL55@oh

delta 262
zcmex%TVVP<fem~5C-=@{pKRXFSD&1pS6re{lv-Q>WTZOh=ar=9l_=OMcm@0W=@ldv
z6{l(>mM3PGC}aZFCFbM=K~?INq*jz@Xlhz>aVbDSW}1Qqnn8LQiN*fqc>zWF1*t_P
zl^W_rsVSL7smUeknwkpLK%Jp5&E=VSDf#7kIr+(nC7JnodKpEjX+US7n+MZx4Y#!x
v%BocW>P*g1&`2#RnyfxaZt|pl3C7yVyuA|5m;2i<_cH=9)Aq~#%*yisk;q&$

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index a55a93570d0b0daefc94f1720feb89420cbc00b6..3fff11ee5d25fc3b6b6ae9e5b3f8b85b2c2e5c56 100644
GIT binary patch
delta 39
vcmbQZhyTKM{)R1#?vd?5QH<MzqL{XoY`43>WXH>7>DOL&muY(4UFOLEJg*Ou

delta 486
zcmZ9Gu}i~16voLpIcOKV=yJF;!6V>YIy{on*4oA@9fDm#dNqM^373Pkh}f-jamYU+
z6hT~_ithbK9KE|(gbv4jkN5q)?|rW;tM|&{)oA(8BZjfybNr|T#$m!TfjP}mDX5b$
zL7Bh}e0Asb?KI#SHG@2e1&*aJm`-p1oVK7dVOmyw#LA#}g3Y}cI|?!{XE!`aDHn6I
z#_8CPISob4vUUexoX2dO<n}ZP0}&_8jyRpj3--e#tXbKp4u4YL6P8REVUu!RtnQ7o
z;@mf?OV>Bnu8-bC4ZvyFJKz9m0yIenprgjVR-^~eBNTuZ$Dio*@PAx<p_4XoBwd$}
zH_He`m!M+wnuGvhsfGZ<QmHCMq^@o#E+0^}tKk+vOBa^L0l+{j<rXgK0(6zUCfca_
fi3i}7<1RKTJ~rrL{j)*N5t@0+#ugQg?ZL}0^4^)i

diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 2a89442db..201989f0b 100644
--- a/sveltejs/src/InstallListProcessor.js
+++ b/sveltejs/src/InstallListProcessor.js
@@ -109,16 +109,6 @@ export const activateProject = async (projectIds) => {
     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);
-  }
 };
 
 /**
-- 
GitLab


From d1d1efc344bc1b7f31f9da98eebff6267a27f1c5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:06:13 -0500
Subject: [PATCH 10/45] Log activation error

---
 src/Controller/InstallerController.php | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index b84ac56fe..9d56abf01 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -423,12 +423,14 @@ final class InstallerController extends ControllerBase {
         $this->installState->setState($project_id, 'installed');
       }
       catch (\Throwable $e) {
+        $message = $e->getMessage();
         $response->addCommand(new MessageCommand(
-          $e->getMessage(),
+          $message,
           options: [
             'type' => MessengerInterface::TYPE_ERROR,
           ],
         ));
+        $this->logger->error($message);
       }
     }
     $this->installState->deleteAll();
-- 
GitLab


From bfeb65ec9de909cbc3b7c6ad69172d8fc87348b7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:07:43 -0500
Subject: [PATCH 11/45] Always attach AJAX to PB

---
 project_browser.libraries.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/project_browser.libraries.yml b/project_browser.libraries.yml
index eb86d09ec..1716ee1fd 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
-- 
GitLab


From 3abfc5e2c4116da976a8b9d257a0fa7451de7c16 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:24:58 -0500
Subject: [PATCH 12/45] Make it use AJAX

---
 src/Controller/InstallerController.php |  10 ++++++----
 sveltejs/public/build/bundle.js        | Bin 273910 -> 273818 bytes
 sveltejs/public/build/bundle.js.map    | Bin 252880 -> 252696 bytes
 sveltejs/src/InstallListProcessor.js   |  24 +++++++++++-------------
 4 files changed, 17 insertions(+), 17 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 9d56abf01..95f753605 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -410,16 +410,18 @@ final class InstallerController extends ControllerBase {
    */
   public function activate(Request $request): JsonResponse {
     $response = new AjaxResponse();
-    foreach ($request->toArray() as $project_id) {
+
+    foreach ($request->get('projects', []) as $project_id) {
       $this->installState->setState($project_id, 'activating');
+
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
 
         $commands = $this->activationManager->activate($project);
-        if ($commands && !Inspector::assertAllObjects($commands, CommandInterface::class)) {
-          throw new \RuntimeException('Activators can only return \Drupal\Core\Ajax\CommandInterface objects, or NULL.');
+        if ($commands) {
+          Inspector::assertAllObjects($commands, CommandInterface::class) or throw new \RuntimeException('Activators can only return \Drupal\Core\Ajax\CommandInterface objects, or NULL.');
+          array_walk($commands, $response->addCommand(...));
         }
-        array_walk($commands, $response->addCommand(...));
         $this->installState->setState($project_id, 'installed');
       }
       catch (\Throwable $e) {
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 1d86c1ef9b510c40196fb92b18c344b068c94780..9bb526895ac6ddcd5b8d4f8211b57cefc2c70ccb 100644
GIT binary patch
delta 207
zcmex%TVU30fem5J)4xS9OKlEl{>`P9ms+miQdC-yn4{;Il~|#{r2qwar8zk|Fy3^>
zEzEK}aIQi%*JS@GmYIpkC7ET3C8-Gr83l#n(xlwX5-Xq@m{B0QpeR2pHMykN3dZtG
rDMr>+i{QYSntG`fsmY}!sTz~Hd!?E$_qSi}X9QxV?U(zRmFEEf2Twee

delta 168
zcmbPrTj1Mmfem5Jo6DNNa&af;=M|SIlosVE*iL4gqAi)2T#{LqSdyAx&Bdhv1`rjQ
zldC4mvIEuWC{%MzJ}_B<J0mqQCAFy73dFabZYEHx1J$6Qkd&WNX*K!bbZJSLSgj`9
syv#HO4aLmKyC&*0W=?)TQGD_;DFMdX=J@{h_<lwpX4)R#&ul#p01>q}T>t<8

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index 3fff11ee5d25fc3b6b6ae9e5b3f8b85b2c2e5c56..a7684502f91867a549ff73a79a93887f08476ac7 100644
GIT binary patch
delta 446
zcmcbxoqxtQ{)R1#t9_?4mNCj~U+K%}&83i+TCU(yR9cXjqvx2FSfLS<rvL<br8zk|
zVCHni?~HOH5Vk@!L{>qev?#}F`u}i7lVGTff<kd=Qf_966+(@ILP1e}R%&udu@#Kv
znNkc_UkhbHXidG;iqz!Nl2i>%>vq>j#_g_=Oh+>f%3O7P9UXmjTpb;eS?-RG?hpxQ
zM@MICAU1Te_H=aipFVLnlhpLQ6U-db*LO0pZ#O;66u_zL>FDT*P_6^wf>dWYfthfA
z5STSxa3-^?pfgy3GhEJV`hx%_k?A=XnFJz=ftD6K>wv8W8e{1KF&pkskk%BPKu5<w
z2my9{sH0;jNW#=f$KTP>AH)pM@pg3d2C;l}Kt7rtc#TPf6|8$Z<2fcPUPjY)o7+s&
JZEiD90RR$Tfv*4n

delta 507
zcmbQSjsL=S{)R1#t9`eh^kwwqnto4%Nn(0u6r<eq{s=}+KI@pgm^=lA<ovwi5{1m^
zFQXV`IZBIibQG#%@}>*KGD`Afq$Z}M78P58MO+gY^?7S`Kw1<OlJZk3t)|~kV3ZaC
zi_~gDP0LJE&``{rt`^Ox!=DK>I58(DD7Cl%Xh-Vwb!m)Z?QbI(x4(^GI-JRr;ky07
zA*M=BPG3hyUmbTxNB8M_&oW8X7dtyT7CY+zNeE#LWaPM7dpbHh>bN>Oy6S+{r#d>O
zf;B<7-j0sm5G^2s!0JFcjGc8n9UVO(YT%kdGC58V8GlDdf4K4_ph69xW0D;mlOc9x
zJ2^ULJApJ*IO~8c0Wp2RTBirLFo{n0xx^%#1`;fAhBybs-98{0sI$Qi0n38j4rYO!
z1R_A{jG;~f`!C$lF&yF{xa#TuJ(xsUK`JAr>s(=y-5zt6Nu8I)(ofrY`b05i$@T@e
Ln5Hkd#asaZ6Udh9

diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 201989f0b..222eb3060 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,20 +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',
+  new Drupal.Ajax(
+    null,
+    document.createElement('div'),
+    {
+      url: `${BASE_URL}admin/modules/project_browser/activate`,
+      submit: {
+        projects: projectIds,
+      },
     },
-    body: JSON.stringify(projectIds),
-  });
-
-  if (!installResponse.ok) {
-    await handleError(installResponse);
-    return;
-  }
+  ).execute();
 };
 
 /**
-- 
GitLab


From ea4532e1bbfdabf4fad0632966e58bc1d83922db Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:38:24 -0500
Subject: [PATCH 13/45] Create a dedicated AJAX command to refresh the project

---
 src/Controller/InstallerController.php | 12 ++++++++---
 src/RefreshProjectCommand.php          | 28 ++++++++++++++++++++++++++
 2 files changed, 37 insertions(+), 3 deletions(-)
 create mode 100644 src/RefreshProjectCommand.php

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 95f753605..02fb2cb50 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -2,7 +2,6 @@
 
 namespace Drupal\project_browser\Controller;
 
-use Drupal\Component\Assertion\Inspector;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Ajax\AjaxResponse;
@@ -18,6 +17,7 @@ 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;
@@ -413,16 +413,22 @@ final class InstallerController extends ControllerBase {
 
     foreach ($request->get('projects', []) as $project_id) {
       $this->installState->setState($project_id, 'activating');
+      [$source_id] = explode('/', $project_id, 2);
 
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
 
         $commands = $this->activationManager->activate($project);
         if ($commands) {
-          Inspector::assertAllObjects($commands, CommandInterface::class) or throw new \RuntimeException('Activators can only return \Drupal\Core\Ajax\CommandInterface objects, or NULL.');
-          array_walk($commands, $response->addCommand(...));
+          foreach ($commands as $command) {
+            assert($command instanceof CommandInterface);
+            $response->addCommand($command);
+          }
         }
         $this->installState->setState($project_id, 'installed');
+
+        $project = $this->normalizer->normalize($project, context: ['source' => $source_id]);
+        $response->addCommand(new RefreshProjectCommand($project));
       }
       catch (\Throwable $e) {
         $message = $e->getMessage();
diff --git a/src/RefreshProjectCommand.php b/src/RefreshProjectCommand.php
new file mode 100644
index 000000000..c6fab42a0
--- /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,
+    ];
+  }
+
+}
-- 
GitLab


From 9dbfc93e02de3a4e866d59a4dd5012d60329f496 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:44:32 -0500
Subject: [PATCH 14/45] Oh, Stan

---
 src/Controller/InstallerController.php | 9 +++------
 1 file changed, 3 insertions(+), 6 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 02fb2cb50..0d753b6e4 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -5,7 +5,6 @@ namespace Drupal\project_browser\Controller;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Ajax\AjaxResponse;
-use Drupal\Core\Ajax\CommandInterface;
 use Drupal\Core\Ajax\MessageCommand;
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Messenger\MessengerInterface;
@@ -419,15 +418,13 @@ final class InstallerController extends ControllerBase {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
 
         $commands = $this->activationManager->activate($project);
-        if ($commands) {
-          foreach ($commands as $command) {
-            assert($command instanceof CommandInterface);
-            $response->addCommand($command);
-          }
+        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) {
-- 
GitLab


From 2980ac4d6d6e3b99405afb83ad388c3eacb0f6c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:55:52 -0500
Subject: [PATCH 15/45] Always return AjaxResponse

---
 src/Controller/InstallerController.php | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 0d753b6e4..0960f32bd 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -404,10 +404,10 @@ final class InstallerController extends ControllerBase {
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The request.
    *
-   * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   Return an AJAX response, or an error message.
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   A response that can be used by the client-side AJAX system.
    */
-  public function activate(Request $request): JsonResponse {
+  public function activate(Request $request): AjaxResponse {
     $response = new AjaxResponse();
 
     foreach ($request->get('projects', []) as $project_id) {
-- 
GitLab


From f7edc39362fe438bdee0b5929f0d84b72a3c01e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 16:21:50 -0500
Subject: [PATCH 16/45] Refactor InstallerControllerTest

---
 .../Functional/InstallerControllerTest.php    | 65 ++++++++++---------
 1 file changed, 34 insertions(+), 31 deletions(-)

diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index cfe51d95b..3baf8e402 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -23,7 +23,9 @@ use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\InstallState;
 use Drupal\project_browser_test\Datetime\TestTime;
 use GuzzleHttp\RequestOptions;
+use PHP_CodeSniffer\Tokenizers\JS;
 use Psr\Http\Message\ResponseInterface;
+use function Symfony\Component\String\s;
 
 // cspell:ignore crashmore
 
@@ -180,9 +182,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());
   }
@@ -207,9 +210,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');
@@ -312,9 +316,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());
   }
@@ -328,9 +333,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());
   }
@@ -344,9 +350,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());
   }
@@ -546,7 +553,10 @@ 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()));
 
@@ -574,28 +584,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),
+    ]);
   }
 
   /**
-- 
GitLab


From 61a684949254506798ebd0f4255e3c7c7670c7e5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 16:24:46 -0500
Subject: [PATCH 17/45] Fix InstallerControllerTest

---
 src/Controller/InstallerController.php        |   6 +++++-
 sveltejs/public/build/bundle.js               | Bin 273818 -> 273828 bytes
 sveltejs/public/build/bundle.js.map           | Bin 252696 -> 252731 bytes
 sveltejs/src/InstallListProcessor.js          |   2 +-
 .../Functional/InstallerControllerTest.php    |   2 +-
 5 files changed, 7 insertions(+), 3 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 0960f32bd..c0db9bc55 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -410,7 +410,11 @@ final class InstallerController extends ControllerBase {
   public function activate(Request $request): AjaxResponse {
     $response = new AjaxResponse();
 
-    foreach ($request->get('projects', []) as $project_id) {
+    $projects = $request->getPayload()->get('projects') ?? [];
+    if ($projects) {
+      $projects = explode(',', $projects);
+    }
+    foreach ($projects as $project_id) {
       $this->installState->setState($project_id, 'activating');
       [$source_id] = explode('/', $project_id, 2);
 
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 9bb526895ac6ddcd5b8d4f8211b57cefc2c70ccb..d59cef5586eb27dd4123b401cab5ee01644a81ac 100644
GIT binary patch
delta 41
vcmbPrTVTm;feps}TzXmgnRy!OI_jE}6Q+nad-b<_^)mu7({`_Z=74zsP+$*u

delta 30
kcmZ2-TVU30feps}lOIeLZw~2i59wzFVy5jO{mcRL0N_v!YybcN

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index a7684502f91867a549ff73a79a93887f08476ac7..faf0e8c683a2111385c42585c547284798c687d2 100644
GIT binary patch
delta 64
zcmV-G0Kfm3whz0u4}i1*$XE(4YHw+7C?_l@DTl~d0k_Cl0!eh2#*qRQmrzFn1O_`v
WK|^#ym+#X86^AU%0=F#91MCeC`xn3f

delta 38
ucmdnJjeo{A{)R1#CnDM}MKEr^6v5<K!e;I1=xplLZgrMvyVY4{Mm_*MN)BoO

diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 222eb3060..5e02ac6f7 100644
--- a/sveltejs/src/InstallListProcessor.js
+++ b/sveltejs/src/InstallListProcessor.js
@@ -103,7 +103,7 @@ export const activateProject = async (projectIds) => {
     {
       url: `${BASE_URL}admin/modules/project_browser/activate`,
       submit: {
-        projects: projectIds,
+        projects: projectIds.join(','),
       },
     },
   ).execute();
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 3baf8e402..b8707411c 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -555,7 +555,7 @@ class InstallerControllerTest extends BrowserTestBase {
 
     $response = $this->getPostResponse(
       Url::fromRoute('project_browser.activate'),
-      ['projects' => ['drupal_core/views_ui']],
+      ['projects' => 'drupal_core/views_ui'],
     );
     $this->assertSame(200, $response->getStatusCode());
     $this->assertTrue(json_validate((string) $response->getBody()));
-- 
GitLab


From 00ab1b3d862d893d0fa66ab2c54371170f23b6ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 16:27:24 -0500
Subject: [PATCH 18/45] Remove pointless call to rebuildContainer()

---
 src/Controller/InstallerController.php           | 2 ++
 tests/src/Functional/InstallerControllerTest.php | 3 ---
 2 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index c0db9bc55..47823f27e 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -412,8 +412,10 @@ final class InstallerController extends ControllerBase {
 
     $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);
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index b8707411c..4cd597c70 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -23,9 +23,7 @@ use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\InstallState;
 use Drupal\project_browser_test\Datetime\TestTime;
 use GuzzleHttp\RequestOptions;
-use PHP_CodeSniffer\Tokenizers\JS;
 use Psr\Http\Message\ResponseInterface;
-use function Symfony\Component\String\s;
 
 // cspell:ignore crashmore
 
@@ -560,7 +558,6 @@ class InstallerControllerTest extends BrowserTestBase {
     $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');
-- 
GitLab


From 6e6b66f5fbe418429ba276d14e4bc3cbd00cdda5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 17:14:53 -0500
Subject: [PATCH 19/45] Adjust one test to account for the behavior change

---
 .../FunctionalJavascript/ProjectBrowserInstallerUiTest.php  | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 9673f0e00..558fb3fe3 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -98,15 +98,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 = $assert_session->waitForElementVisible('css', '.project-browser-popup');
-    $this->assertNotEmpty($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->assertTrue($assert_session->waitForText($message));
+    $assert_session->statusMessageContains($message, 'error');
   }
 
   /**
-- 
GitLab


From 93a567f3c3eec968b2b75d344eddc8158f537448 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 17:49:51 -0500
Subject: [PATCH 20/45] Give a few seconds for AJAX to set up...I think

---
 .../src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php  | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 558fb3fe3..2fffe684a 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -132,6 +132,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     // If we reload, the installation status should be remembered.
     $this->getSession()->reload();
+    // Give the AJAX system a few seconds to attach behaviors.
+    sleep(3);
     $this->inputSearchField('image', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")
       ?->click();
-- 
GitLab


From 35f75522334ea700f06df8e0be721c642094657a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 18:01:24 -0500
Subject: [PATCH 21/45] Revert "Give a few seconds for AJAX to set up...I
 think"

This reverts commit 93a567f3c3eec968b2b75d344eddc8158f537448.
---
 .../src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php  | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 2fffe684a..558fb3fe3 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -132,8 +132,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     // If we reload, the installation status should be remembered.
     $this->getSession()->reload();
-    // Give the AJAX system a few seconds to attach behaviors.
-    sleep(3);
     $this->inputSearchField('image', TRUE);
     $assert_session->waitForElementVisible('css', ".search__search-submit")
       ?->click();
-- 
GitLab


From bab0bd11a8d2d4c2dbae03740d6e18b4f7b95865 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 18:10:26 -0500
Subject: [PATCH 22/45] My kingdom for an await

---
 sveltejs/public/build/bundle.js      | Bin 273828 -> 273834 bytes
 sveltejs/public/build/bundle.js.map  | Bin 252731 -> 252742 bytes
 sveltejs/src/InstallListProcessor.js |   2 +-
 3 files changed, 1 insertion(+), 1 deletion(-)

diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index d59cef5586eb27dd4123b401cab5ee01644a81ac..06911682ad912a62539969da86210c79735cd569 100644
GIT binary patch
delta 32
mcmZ2-TVT~~feqPxtcm4`nI+9xeeGF&j6lq^J*$t|YCZt->kaS#

delta 26
gcmZ2=TVTm;feqPx&4qpKg?)@b%(T6*kJ)lQ0Iop`@Bjb+

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index faf0e8c683a2111385c42585c547284798c687d2..6507d085be0457013a20ebb51fd977351053032e 100644
GIT binary patch
delta 67
zcmdnJjsMs-{)R1#LJ_Qq<%yXk?Sc`E+XW+-f=Xn49UXmjJRKc9b-*lVM@MHc*V@z3
QxvahV4Ab`NGt3A00P^M)@c;k-

delta 38
ucmX@Mjeqwx{)R1#LJ{o}5scd<BA5b8rsthu5@*b6Z#%=Zz3mM10X_g9fexDh

diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 5e02ac6f7..67025be16 100644
--- a/sveltejs/src/InstallListProcessor.js
+++ b/sveltejs/src/InstallListProcessor.js
@@ -97,7 +97,7 @@ export const handleError = async (errorResponse) => {
  *   A promise that resolves when the project is activated.
  */
 export const activateProject = async (projectIds) => {
-  new Drupal.Ajax(
+  await new Drupal.Ajax(
     null,
     document.createElement('div'),
     {
-- 
GitLab


From 3b000e2517756aa33cc8baf5e221766a02edcd36 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 21 Feb 2025 14:56:21 -0500
Subject: [PATCH 23/45] Use logException

---
 src/Controller/InstallerController.php | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 5043d692f..d2e2c90cc 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -9,6 +9,7 @@ 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;
@@ -441,7 +442,7 @@ final class InstallerController extends ControllerBase {
             'type' => MessengerInterface::TYPE_ERROR,
           ],
         ));
-        $this->logger->error($message);
+        Error::logException($this->logger, $e);
       }
     }
     $this->installState->deleteAll();
-- 
GitLab


From 8176d4ca96683a0125eea3dd06936bd1d5253e85 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 18:23:17 -0500
Subject: [PATCH 24/45] Sketch in the normalizer

---
 project_browser.services.yml                  |  1 +
 .../ProjectBrowserEndpointController.php      | 10 ++--
 src/ProjectBrowser/Normalizer.php             | 54 +++++++++++++++++++
 3 files changed, 61 insertions(+), 4 deletions(-)
 create mode 100644 src/ProjectBrowser/Normalizer.php

diff --git a/project_browser.services.yml b/project_browser.services.yml
index 4e2e42215..05c3bcd98 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/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index b75d3c938..4b91e3db3 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -4,8 +4,8 @@ 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\Normalizer;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 use Drupal\system\Form\ModulesUninstallForm;
@@ -14,6 +14,7 @@ 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 +23,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
 
   public function __construct(
     private readonly EnabledSourceHandler $enabledSource,
-    private readonly ActivationManager $activationManager,
+    private readonly NormalizerInterface $normalizer,
   ) {}
 
   /**
@@ -31,7 +32,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,7 +55,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
     }
 
     $results = $this->enabledSource->getProjects($query['source'], $query);
-    return new JsonResponse($this->prepareResults($results));
+    return new JsonResponse($this->normalizer->normalize($results));
   }
 
   /**
@@ -68,6 +69,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
    *   all projects.
    */
   private function prepareResults(ProjectsResultsPage $results): array {
+    // @todo Move all of this to the normalizer.
     $data = $results->toArray();
 
     // Add activation info to all the projects in the result set, and fully
diff --git a/src/ProjectBrowser/Normalizer.php b/src/ProjectBrowser/Normalizer.php
new file mode 100644
index 000000000..f84c3a2b4
--- /dev/null
+++ b/src/ProjectBrowser/Normalizer.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\ProjectBrowser;
+
+use Drupal\project_browser\ActivationManager;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+final class Normalizer implements NormalizerInterface {
+
+  public function __construct(
+    private readonly ActivationManager $activationManager,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize(mixed $data, ?string $format = NULL, array $context = []): array {
+    if ($data instanceof Project) {
+      assert(array_key_exists('source', $context));
+      $data = $this->activationManager->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,
+    ];
+  }
+
+}
-- 
GitLab


From 85beae0c922f50d023f2bfd4cb274a7173cb4139 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Tue, 18 Feb 2025 18:56:15 -0500
Subject: [PATCH 25/45] Remove prepareResults()

---
 .../ProjectBrowserEndpointController.php      | 29 -------------------
 1 file changed, 29 deletions(-)

diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index 4b91e3db3..05c23e0b6 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -6,8 +6,6 @@ use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Form\FormState;
 use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\ProjectBrowser\Normalizer;
-use Drupal\project_browser\ProjectBrowser\Project;
-use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 use Drupal\system\Form\ModulesUninstallForm;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -58,33 +56,6 @@ final class ProjectBrowserEndpointController extends ControllerBase {
     return new JsonResponse($this->normalizer->normalize($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 {
-    // @todo Move all of this to the normalizer.
-    $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;
-  }
-
   /**
    * Prepares to uninstall a module.
    *
-- 
GitLab


From a8f52f4e2ec4577ed3e90263c9354b483bcb156c Mon Sep 17 00:00:00 2001
From: narendra-drupal <87118318+narendra-drupal@users.noreply.github.com>
Date: Wed, 19 Feb 2025 16:02:46 +0530
Subject: [PATCH 26/45] getActivationInfo moved to Normalizer

---
 src/ActivationManager.php         | 65 +------------------------------
 src/ProjectBrowser/Normalizer.php | 65 ++++++++++++++++++++++++++++++-
 2 files changed, 65 insertions(+), 65 deletions(-)

diff --git a/src/ActivationManager.php b/src/ActivationManager.php
index 91e78e68d..53adf27d0 100644
--- a/src/ActivationManager.php
+++ b/src/ActivationManager.php
@@ -4,13 +4,8 @@ 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;
 
@@ -29,10 +24,6 @@ final class ActivationManager {
    */
   private array $activators = [];
 
-  public function __construct(
-    private readonly RendererInterface $renderer,
-  ) {}
-
   /**
    * Registers an activator.
    *
@@ -71,7 +62,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,60 +71,6 @@ 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.
    *
diff --git a/src/ProjectBrowser/Normalizer.php b/src/ProjectBrowser/Normalizer.php
index f84c3a2b4..eafa7431c 100644
--- a/src/ProjectBrowser/Normalizer.php
+++ b/src/ProjectBrowser/Normalizer.php
@@ -4,13 +4,22 @@ 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,
   ) {}
 
   /**
@@ -19,7 +28,7 @@ final class Normalizer implements NormalizerInterface {
   public function normalize(mixed $data, ?string $format = NULL, array $context = []): array {
     if ($data instanceof Project) {
       assert(array_key_exists('source', $context));
-      $data = $this->activationManager->getActivationInfo($data) + $data->toArray();
+      $data = $this->getActivationInfo($data) + $data->toArray();
       $data['id'] = $context['source'] . '/' . $data['id'];
     }
     elseif ($data instanceof ProjectsResultsPage) {
@@ -51,4 +60,58 @@ final class Normalizer implements NormalizerInterface {
     ];
   }
 
+  /**
+   * 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;
+  }
+
 }
-- 
GitLab


From 3b548e4c6b8cea42de87cfb0b7545fe8d4fbcd2f Mon Sep 17 00:00:00 2001
From: narendra-drupal <87118318+narendra-drupal@users.noreply.github.com>
Date: Wed, 19 Feb 2025 16:59:44 +0530
Subject: [PATCH 27/45] return normalized projects in activate response

---
 src/Controller/InstallerController.php | 19 ++++++++++++-------
 1 file changed, 12 insertions(+), 7 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index d7ba08c98..a723a24fd 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -12,13 +12,14 @@ 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\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 +44,7 @@ final class InstallerController extends ControllerBase {
     private readonly ActivationManager $activationManager,
     private readonly InstallState $installState,
     private readonly EventDispatcherInterface $eventDispatcher,
+    private readonly NormalizerInterface $normalizer,
   ) {}
 
   /**
@@ -61,6 +63,7 @@ final class InstallerController extends ControllerBase {
       $container->get(ActivationManager::class),
       $container->get(InstallState::class),
       $container->get(EventDispatcherInterface::class),
+      $container->get(Normalizer::class),
     );
   }
 
@@ -392,21 +395,23 @@ 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 \Symfony\Component\HttpFoundation\JsonResponse
+   *   Returns normalized activated project data or an error message.
    */
-  public function activate(Request $request): Response {
+  public function activate(Request $request): JsonResponse {
+    $normalized_projects = [];
     foreach ($request->toArray() as $project_id) {
       $this->installState->setState($project_id, 'activating');
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
-        $response = $this->activationManager->activate($project);
+        $this->activationManager->activate($project);
         $this->installState->setState($project_id, 'installed');
+        $normalized_projects[] = $this->normalizer->normalize($project);
       }
       catch (\Throwable $e) {
         return $this->errorResponse($e, 'project install');
@@ -415,7 +420,7 @@ final class InstallerController extends ControllerBase {
         $this->installState->deleteAll();
       }
     }
-    return $response ?? new JsonResponse(['status' => 0]);
+    return new JsonResponse($normalized_projects);
   }
 
   /**
-- 
GitLab


From d02c9e9bdd69b83dd0dac417b8b83ab2e3a6d1a4 Mon Sep 17 00:00:00 2001
From: narendra-drupal <87118318+narendra-drupal@users.noreply.github.com>
Date: Wed, 19 Feb 2025 17:47:45 +0530
Subject: [PATCH 28/45] Added source

---
 src/Controller/InstallerController.php | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index a723a24fd..e06ffc0a2 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -409,9 +409,10 @@ final class InstallerController extends ControllerBase {
       $this->installState->setState($project_id, 'activating');
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
+        $plugin_id = strstr($project_id, '/', TRUE);
         $this->activationManager->activate($project);
         $this->installState->setState($project_id, 'installed');
-        $normalized_projects[] = $this->normalizer->normalize($project);
+        $normalized_projects[] = $this->normalizer->normalize($project, 'json', ['source' => $plugin_id]);
       }
       catch (\Throwable $e) {
         return $this->errorResponse($e, 'project install');
-- 
GitLab


From e1af8d1a02b75eef1067f1b54d3a12ff85882ba9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 14:52:36 -0500
Subject: [PATCH 29/45] Make all activators return an array of AJAX commands

---
 src/ActivationManager.php            |  9 ++++-----
 src/Activator/ActivatorInterface.php |  9 +++------
 src/Activator/ModuleActivator.php    |  8 ++------
 src/Activator/RecipeActivator.php    | 19 ++++++-------------
 4 files changed, 15 insertions(+), 30 deletions(-)

diff --git a/src/ActivationManager.php b/src/ActivationManager.php
index 53adf27d0..b2a89d789 100644
--- a/src/ActivationManager.php
+++ b/src/ActivationManager.php
@@ -7,7 +7,6 @@ namespace Drupal\project_browser;
 use Drupal\project_browser\Activator\ActivationStatus;
 use Drupal\project_browser\Activator\ActivatorInterface;
 use Drupal\project_browser\ProjectBrowser\Project;
-use Symfony\Component\HttpFoundation\Response;
 
 /**
  * A generalized activator that can handle any type of project.
@@ -77,11 +76,11 @@ final class ActivationManager {
    * @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 5fc5a8639..ea28361b7 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 9389d7173..6edee6617 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 5af9c6e77..2cff5fc00 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;
   }
 
   /**
-- 
GitLab


From 93bbda43d22b492379d48446b987a790f97e0cf9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 14:59:41 -0500
Subject: [PATCH 30/45] Make InstallerController deal with AJAX commands

---
 src/Controller/InstallerController.php | 31 +++++++++++++++++---------
 1 file changed, 21 insertions(+), 10 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index e06ffc0a2..c524d1130 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -2,9 +2,14 @@
 
 namespace Drupal\project_browser\Controller;
 
+use Drupal\Component\Assertion\Inspector;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\CommandInterface;
+use Drupal\Core\Ajax\MessageCommand;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\Url;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\StatusCheckTrait;
@@ -401,27 +406,33 @@ final class InstallerController extends ControllerBase {
    *   The request.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   Returns normalized activated project data or an error message.
+   *   Return an AJAX response, or an error message.
    */
   public function activate(Request $request): JsonResponse {
-    $normalized_projects = [];
+    $response = new AjaxResponse();
     foreach ($request->toArray() as $project_id) {
       $this->installState->setState($project_id, 'activating');
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
-        $plugin_id = strstr($project_id, '/', TRUE);
-        $this->activationManager->activate($project);
+
+        $commands = $this->activationManager->activate($project);
+        if ($commands && !Inspector::assertAllObjects($commands, CommandInterface::class)) {
+          throw new \RuntimeException('Activators can only return \Drupal\Core\Ajax\CommandInterface objects, or NULL.');
+        }
+        array_walk($commands, $response->addCommand(...));
         $this->installState->setState($project_id, 'installed');
-        $normalized_projects[] = $this->normalizer->normalize($project, 'json', ['source' => $plugin_id]);
       }
       catch (\Throwable $e) {
-        return $this->errorResponse($e, 'project install');
-      }
-      finally {
-        $this->installState->deleteAll();
+        $response->addCommand(new MessageCommand(
+          $e->getMessage(),
+          options: [
+            'type' => MessengerInterface::TYPE_ERROR,
+          ],
+        ));
       }
     }
-    return new JsonResponse($normalized_projects);
+    $this->installState->deleteAll();
+    return $response;
   }
 
   /**
-- 
GitLab


From 2b8b99c0ca3049c7233f36aef23c8b00f5573863 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:02:29 -0500
Subject: [PATCH 31/45] Remove redirect handling from Svelte code

---
 sveltejs/public/build/bundle.js      | Bin 274199 -> 273910 bytes
 sveltejs/public/build/bundle.js.map  | Bin 253457 -> 252880 bytes
 sveltejs/src/InstallListProcessor.js |  10 ----------
 3 files changed, 10 deletions(-)

diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 1f08f0e940735a257cf0e693dc30fba071e420a7..1d86c1ef9b510c40196fb92b18c344b068c94780 100644
GIT binary patch
delta 30
lcmbP!PvF~afem~5C-e1+H-DMX{$&Cq5HoH6GJ#on9svIL55@oh

delta 262
zcmex%TVVP<fem~5C-=@{pKRXFSD&1pS6re{lv-Q>WTZOh=ar=9l_=OMcm@0W=@ldv
z6{l(>mM3PGC}aZFCFbM=K~?INq*jz@Xlhz>aVbDSW}1Qqnn8LQiN*fqc>zWF1*t_P
zl^W_rsVSL7smUeknwkpLK%Jp5&E=VSDf#7kIr+(nC7JnodKpEjX+US7n+MZx4Y#!x
v%BocW>P*g1&`2#RnyfxaZt|pl3C7yVyuA|5m;2i<_cH=9)Aq~#%*yisk;q&$

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index a55a93570d0b0daefc94f1720feb89420cbc00b6..3fff11ee5d25fc3b6b6ae9e5b3f8b85b2c2e5c56 100644
GIT binary patch
delta 39
vcmbQZhyTKM{)R1#?vd?5QH<MzqL{XoY`43>WXH>7>DOL&muY(4UFOLEJg*Ou

delta 486
zcmZ9Gu}i~16voLpIcOKV=yJF;!6V>YIy{on*4oA@9fDm#dNqM^373Pkh}f-jamYU+
z6hT~_ithbK9KE|(gbv4jkN5q)?|rW;tM|&{)oA(8BZjfybNr|T#$m!TfjP}mDX5b$
zL7Bh}e0Asb?KI#SHG@2e1&*aJm`-p1oVK7dVOmyw#LA#}g3Y}cI|?!{XE!`aDHn6I
z#_8CPISob4vUUexoX2dO<n}ZP0}&_8jyRpj3--e#tXbKp4u4YL6P8REVUu!RtnQ7o
z;@mf?OV>Bnu8-bC4ZvyFJKz9m0yIenprgjVR-^~eBNTuZ$Dio*@PAx<p_4XoBwd$}
zH_He`m!M+wnuGvhsfGZ<QmHCMq^@o#E+0^}tKk+vOBa^L0l+{j<rXgK0(6zUCfca_
fi3i}7<1RKTJ~rrL{j)*N5t@0+#ugQg?ZL}0^4^)i

diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 2a89442db..201989f0b 100644
--- a/sveltejs/src/InstallListProcessor.js
+++ b/sveltejs/src/InstallListProcessor.js
@@ -109,16 +109,6 @@ export const activateProject = async (projectIds) => {
     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);
-  }
 };
 
 /**
-- 
GitLab


From 56ad83f8503db2ffa646c6d1323218c04714968b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:06:13 -0500
Subject: [PATCH 32/45] Log activation error

---
 src/Controller/InstallerController.php | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index c524d1130..5489a06c1 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -423,12 +423,14 @@ final class InstallerController extends ControllerBase {
         $this->installState->setState($project_id, 'installed');
       }
       catch (\Throwable $e) {
+        $message = $e->getMessage();
         $response->addCommand(new MessageCommand(
-          $e->getMessage(),
+          $message,
           options: [
             'type' => MessengerInterface::TYPE_ERROR,
           ],
         ));
+        $this->logger->error($message);
       }
     }
     $this->installState->deleteAll();
-- 
GitLab


From bd2fc64b7dcfe92885457aee9f3e776a743ff59c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:07:43 -0500
Subject: [PATCH 33/45] Always attach AJAX to PB

---
 project_browser.libraries.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/project_browser.libraries.yml b/project_browser.libraries.yml
index eb86d09ec..1716ee1fd 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
-- 
GitLab


From d8c8d2ac9bf8c4032dd09cacc3ed69e93d02928e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:24:58 -0500
Subject: [PATCH 34/45] Make it use AJAX

---
 src/Controller/InstallerController.php |  10 ++++++----
 sveltejs/public/build/bundle.js        | Bin 273910 -> 273818 bytes
 sveltejs/public/build/bundle.js.map    | Bin 252880 -> 252696 bytes
 sveltejs/src/InstallListProcessor.js   |  24 +++++++++++-------------
 4 files changed, 17 insertions(+), 17 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 5489a06c1..89446059c 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -410,16 +410,18 @@ final class InstallerController extends ControllerBase {
    */
   public function activate(Request $request): JsonResponse {
     $response = new AjaxResponse();
-    foreach ($request->toArray() as $project_id) {
+
+    foreach ($request->get('projects', []) as $project_id) {
       $this->installState->setState($project_id, 'activating');
+
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
 
         $commands = $this->activationManager->activate($project);
-        if ($commands && !Inspector::assertAllObjects($commands, CommandInterface::class)) {
-          throw new \RuntimeException('Activators can only return \Drupal\Core\Ajax\CommandInterface objects, or NULL.');
+        if ($commands) {
+          Inspector::assertAllObjects($commands, CommandInterface::class) or throw new \RuntimeException('Activators can only return \Drupal\Core\Ajax\CommandInterface objects, or NULL.');
+          array_walk($commands, $response->addCommand(...));
         }
-        array_walk($commands, $response->addCommand(...));
         $this->installState->setState($project_id, 'installed');
       }
       catch (\Throwable $e) {
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 1d86c1ef9b510c40196fb92b18c344b068c94780..9bb526895ac6ddcd5b8d4f8211b57cefc2c70ccb 100644
GIT binary patch
delta 207
zcmex%TVU30fem5J)4xS9OKlEl{>`P9ms+miQdC-yn4{;Il~|#{r2qwar8zk|Fy3^>
zEzEK}aIQi%*JS@GmYIpkC7ET3C8-Gr83l#n(xlwX5-Xq@m{B0QpeR2pHMykN3dZtG
rDMr>+i{QYSntG`fsmY}!sTz~Hd!?E$_qSi}X9QxV?U(zRmFEEf2Twee

delta 168
zcmbPrTj1Mmfem5Jo6DNNa&af;=M|SIlosVE*iL4gqAi)2T#{LqSdyAx&Bdhv1`rjQ
zldC4mvIEuWC{%MzJ}_B<J0mqQCAFy73dFabZYEHx1J$6Qkd&WNX*K!bbZJSLSgj`9
syv#HO4aLmKyC&*0W=?)TQGD_;DFMdX=J@{h_<lwpX4)R#&ul#p01>q}T>t<8

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index 3fff11ee5d25fc3b6b6ae9e5b3f8b85b2c2e5c56..a7684502f91867a549ff73a79a93887f08476ac7 100644
GIT binary patch
delta 446
zcmcbxoqxtQ{)R1#t9_?4mNCj~U+K%}&83i+TCU(yR9cXjqvx2FSfLS<rvL<br8zk|
zVCHni?~HOH5Vk@!L{>qev?#}F`u}i7lVGTff<kd=Qf_966+(@ILP1e}R%&udu@#Kv
znNkc_UkhbHXidG;iqz!Nl2i>%>vq>j#_g_=Oh+>f%3O7P9UXmjTpb;eS?-RG?hpxQ
zM@MICAU1Te_H=aipFVLnlhpLQ6U-db*LO0pZ#O;66u_zL>FDT*P_6^wf>dWYfthfA
z5STSxa3-^?pfgy3GhEJV`hx%_k?A=XnFJz=ftD6K>wv8W8e{1KF&pkskk%BPKu5<w
z2my9{sH0;jNW#=f$KTP>AH)pM@pg3d2C;l}Kt7rtc#TPf6|8$Z<2fcPUPjY)o7+s&
JZEiD90RR$Tfv*4n

delta 507
zcmbQSjsL=S{)R1#t9`eh^kwwqnto4%Nn(0u6r<eq{s=}+KI@pgm^=lA<ovwi5{1m^
zFQXV`IZBIibQG#%@}>*KGD`Afq$Z}M78P58MO+gY^?7S`Kw1<OlJZk3t)|~kV3ZaC
zi_~gDP0LJE&``{rt`^Ox!=DK>I58(DD7Cl%Xh-Vwb!m)Z?QbI(x4(^GI-JRr;ky07
zA*M=BPG3hyUmbTxNB8M_&oW8X7dtyT7CY+zNeE#LWaPM7dpbHh>bN>Oy6S+{r#d>O
zf;B<7-j0sm5G^2s!0JFcjGc8n9UVO(YT%kdGC58V8GlDdf4K4_ph69xW0D;mlOc9x
zJ2^ULJApJ*IO~8c0Wp2RTBirLFo{n0xx^%#1`;fAhBybs-98{0sI$Qi0n38j4rYO!
z1R_A{jG;~f`!C$lF&yF{xa#TuJ(xsUK`JAr>s(=y-5zt6Nu8I)(ofrY`b05i$@T@e
Ln5Hkd#asaZ6Udh9

diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 201989f0b..222eb3060 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,20 +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',
+  new Drupal.Ajax(
+    null,
+    document.createElement('div'),
+    {
+      url: `${BASE_URL}admin/modules/project_browser/activate`,
+      submit: {
+        projects: projectIds,
+      },
     },
-    body: JSON.stringify(projectIds),
-  });
-
-  if (!installResponse.ok) {
-    await handleError(installResponse);
-    return;
-  }
+  ).execute();
 };
 
 /**
-- 
GitLab


From f9bbbc02ca47946efe5bfd4bc75108e3a0ac673a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:38:24 -0500
Subject: [PATCH 35/45] Create a dedicated AJAX command to refresh the project

---
 src/Controller/InstallerController.php | 12 ++++++++---
 src/RefreshProjectCommand.php          | 28 ++++++++++++++++++++++++++
 2 files changed, 37 insertions(+), 3 deletions(-)
 create mode 100644 src/RefreshProjectCommand.php

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 89446059c..fab7fff9b 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -2,7 +2,6 @@
 
 namespace Drupal\project_browser\Controller;
 
-use Drupal\Component\Assertion\Inspector;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Ajax\AjaxResponse;
@@ -18,6 +17,7 @@ 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;
@@ -413,16 +413,22 @@ final class InstallerController extends ControllerBase {
 
     foreach ($request->get('projects', []) as $project_id) {
       $this->installState->setState($project_id, 'activating');
+      [$source_id] = explode('/', $project_id, 2);
 
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
 
         $commands = $this->activationManager->activate($project);
         if ($commands) {
-          Inspector::assertAllObjects($commands, CommandInterface::class) or throw new \RuntimeException('Activators can only return \Drupal\Core\Ajax\CommandInterface objects, or NULL.');
-          array_walk($commands, $response->addCommand(...));
+          foreach ($commands as $command) {
+            assert($command instanceof CommandInterface);
+            $response->addCommand($command);
+          }
         }
         $this->installState->setState($project_id, 'installed');
+
+        $project = $this->normalizer->normalize($project, context: ['source' => $source_id]);
+        $response->addCommand(new RefreshProjectCommand($project));
       }
       catch (\Throwable $e) {
         $message = $e->getMessage();
diff --git a/src/RefreshProjectCommand.php b/src/RefreshProjectCommand.php
new file mode 100644
index 000000000..c6fab42a0
--- /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,
+    ];
+  }
+
+}
-- 
GitLab


From a45a329e2e1752489365f2b113004470ef8ccc5e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:44:32 -0500
Subject: [PATCH 36/45] Oh, Stan

---
 src/Controller/InstallerController.php | 9 +++------
 1 file changed, 3 insertions(+), 6 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index fab7fff9b..06a7625e2 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -5,7 +5,6 @@ namespace Drupal\project_browser\Controller;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Ajax\AjaxResponse;
-use Drupal\Core\Ajax\CommandInterface;
 use Drupal\Core\Ajax\MessageCommand;
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Messenger\MessengerInterface;
@@ -419,15 +418,13 @@ final class InstallerController extends ControllerBase {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
 
         $commands = $this->activationManager->activate($project);
-        if ($commands) {
-          foreach ($commands as $command) {
-            assert($command instanceof CommandInterface);
-            $response->addCommand($command);
-          }
+        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) {
-- 
GitLab


From 2757a98e15898ffdd1bc4ba3fc14f5b8129543a0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 15:55:52 -0500
Subject: [PATCH 37/45] Always return AjaxResponse

---
 src/Controller/InstallerController.php | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 06a7625e2..be9ce6f34 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -404,10 +404,10 @@ final class InstallerController extends ControllerBase {
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The request.
    *
-   * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   Return an AJAX response, or an error message.
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   A response that can be used by the client-side AJAX system.
    */
-  public function activate(Request $request): JsonResponse {
+  public function activate(Request $request): AjaxResponse {
     $response = new AjaxResponse();
 
     foreach ($request->get('projects', []) as $project_id) {
-- 
GitLab


From 5548d949e330faeafa58e41d586c8bb104835c3c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 16:21:50 -0500
Subject: [PATCH 38/45] Refactor InstallerControllerTest

---
 .../Functional/InstallerControllerTest.php    | 63 ++++++++++---------
 1 file changed, 32 insertions(+), 31 deletions(-)

diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 6b4d6494e..e341517f1 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -180,9 +180,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());
   }
@@ -207,9 +208,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');
@@ -312,9 +314,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());
   }
@@ -328,9 +331,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());
   }
@@ -344,9 +348,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());
   }
@@ -546,7 +551,10 @@ 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()));
 
@@ -574,28 +582,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),
+    ]);
   }
 
   /**
-- 
GitLab


From 6e18bb1605c6a0ee89596462e8f1cab63c42a1f6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 16:24:46 -0500
Subject: [PATCH 39/45] Fix InstallerControllerTest

---
 src/Controller/InstallerController.php        |   6 +++++-
 sveltejs/public/build/bundle.js               | Bin 273818 -> 273828 bytes
 sveltejs/public/build/bundle.js.map           | Bin 252696 -> 252731 bytes
 sveltejs/src/InstallListProcessor.js          |   2 +-
 .../Functional/InstallerControllerTest.php    |   2 +-
 5 files changed, 7 insertions(+), 3 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index be9ce6f34..69934e5ab 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -410,7 +410,11 @@ final class InstallerController extends ControllerBase {
   public function activate(Request $request): AjaxResponse {
     $response = new AjaxResponse();
 
-    foreach ($request->get('projects', []) as $project_id) {
+    $projects = $request->getPayload()->get('projects') ?? [];
+    if ($projects) {
+      $projects = explode(',', $projects);
+    }
+    foreach ($projects as $project_id) {
       $this->installState->setState($project_id, 'activating');
       [$source_id] = explode('/', $project_id, 2);
 
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 9bb526895ac6ddcd5b8d4f8211b57cefc2c70ccb..d59cef5586eb27dd4123b401cab5ee01644a81ac 100644
GIT binary patch
delta 41
vcmbPrTVTm;feps}TzXmgnRy!OI_jE}6Q+nad-b<_^)mu7({`_Z=74zsP+$*u

delta 30
kcmZ2-TVU30feps}lOIeLZw~2i59wzFVy5jO{mcRL0N_v!YybcN

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index a7684502f91867a549ff73a79a93887f08476ac7..faf0e8c683a2111385c42585c547284798c687d2 100644
GIT binary patch
delta 64
zcmV-G0Kfm3whz0u4}i1*$XE(4YHw+7C?_l@DTl~d0k_Cl0!eh2#*qRQmrzFn1O_`v
WK|^#ym+#X86^AU%0=F#91MCeC`xn3f

delta 38
ucmdnJjeo{A{)R1#CnDM}MKEr^6v5<K!e;I1=xplLZgrMvyVY4{Mm_*MN)BoO

diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 222eb3060..5e02ac6f7 100644
--- a/sveltejs/src/InstallListProcessor.js
+++ b/sveltejs/src/InstallListProcessor.js
@@ -103,7 +103,7 @@ export const activateProject = async (projectIds) => {
     {
       url: `${BASE_URL}admin/modules/project_browser/activate`,
       submit: {
-        projects: projectIds,
+        projects: projectIds.join(','),
       },
     },
   ).execute();
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index e341517f1..05011448d 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -553,7 +553,7 @@ class InstallerControllerTest extends BrowserTestBase {
 
     $response = $this->getPostResponse(
       Url::fromRoute('project_browser.activate'),
-      ['projects' => ['drupal_core/views_ui']],
+      ['projects' => 'drupal_core/views_ui'],
     );
     $this->assertSame(200, $response->getStatusCode());
     $this->assertTrue(json_validate((string) $response->getBody()));
-- 
GitLab


From 06f3ef66952abf1f761829604100fa9b62f0dd47 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 16:27:24 -0500
Subject: [PATCH 40/45] Remove pointless call to rebuildContainer()

---
 src/Controller/InstallerController.php           | 2 ++
 tests/src/Functional/InstallerControllerTest.php | 1 -
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 69934e5ab..5043d692f 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -412,8 +412,10 @@ final class InstallerController extends ControllerBase {
 
     $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);
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 05011448d..7eec5d1bc 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -558,7 +558,6 @@ class InstallerControllerTest extends BrowserTestBase {
     $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');
-- 
GitLab


From 2c9030f4f93815dcbbb2f35078cd6ec150a8a9fd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 17:14:53 -0500
Subject: [PATCH 41/45] Adjust one test to account for the behavior change

---
 .../FunctionalJavascript/ProjectBrowserInstallerUiTest.php   | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index f06182970..c29e71767 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');
   }
 
   /**
-- 
GitLab


From 9119d2c0d8e54280d6f4d7097538d366c15bfbce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 17:49:51 -0500
Subject: [PATCH 42/45] Give a few seconds for AJAX to set up...I think

---
 .../src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php  | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index c29e71767..8d3995413 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -130,6 +130,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     // If we reload, the installation status should be remembered.
     $this->getSession()->reload();
+    // Give the AJAX system a few seconds to attach behaviors.
+    sleep(3);
     $this->inputSearchField('image', TRUE);
     $this->assertElementIsVisible('css', ".search__search-submit")->click();
     $card = $this->waitForProject('Image media type');
-- 
GitLab


From 9a54e675564612b1bd85dd3785d9b411ec8f294e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 18:01:24 -0500
Subject: [PATCH 43/45] Revert "Give a few seconds for AJAX to set up...I
 think"

This reverts commit 93a567f3c3eec968b2b75d344eddc8158f537448.
---
 .../src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php  | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 8d3995413..c29e71767 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -130,8 +130,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
 
     // If we reload, the installation status should be remembered.
     $this->getSession()->reload();
-    // Give the AJAX system a few seconds to attach behaviors.
-    sleep(3);
     $this->inputSearchField('image', TRUE);
     $this->assertElementIsVisible('css', ".search__search-submit")->click();
     $card = $this->waitForProject('Image media type');
-- 
GitLab


From 01deb45e8af2028ba2ca3c3482493db01c64934b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Thu, 20 Feb 2025 18:10:26 -0500
Subject: [PATCH 44/45] My kingdom for an await

---
 sveltejs/public/build/bundle.js      | Bin 273828 -> 273834 bytes
 sveltejs/public/build/bundle.js.map  | Bin 252731 -> 252742 bytes
 sveltejs/src/InstallListProcessor.js |   2 +-
 3 files changed, 1 insertion(+), 1 deletion(-)

diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index d59cef5586eb27dd4123b401cab5ee01644a81ac..06911682ad912a62539969da86210c79735cd569 100644
GIT binary patch
delta 32
mcmZ2-TVT~~feqPxtcm4`nI+9xeeGF&j6lq^J*$t|YCZt->kaS#

delta 26
gcmZ2=TVTm;feqPx&4qpKg?)@b%(T6*kJ)lQ0Iop`@Bjb+

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index faf0e8c683a2111385c42585c547284798c687d2..6507d085be0457013a20ebb51fd977351053032e 100644
GIT binary patch
delta 67
zcmdnJjsMs-{)R1#LJ_Qq<%yXk?Sc`E+XW+-f=Xn49UXmjJRKc9b-*lVM@MHc*V@z3
QxvahV4Ab`NGt3A00P^M)@c;k-

delta 38
ucmX@Mjeqwx{)R1#LJ{o}5scd<BA5b8rsthu5@*b6Z#%=Zz3mM10X_g9fexDh

diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 5e02ac6f7..67025be16 100644
--- a/sveltejs/src/InstallListProcessor.js
+++ b/sveltejs/src/InstallListProcessor.js
@@ -97,7 +97,7 @@ export const handleError = async (errorResponse) => {
  *   A promise that resolves when the project is activated.
  */
 export const activateProject = async (projectIds) => {
-  new Drupal.Ajax(
+  await new Drupal.Ajax(
     null,
     document.createElement('div'),
     {
-- 
GitLab


From 5df425de4e2dc2e7b9b236176212fdf5ab0159bb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= <adam@phenaproxima.net>
Date: Fri, 21 Feb 2025 14:56:21 -0500
Subject: [PATCH 45/45] Use logException

---
 src/Controller/InstallerController.php | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 5043d692f..d2e2c90cc 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -9,6 +9,7 @@ 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;
@@ -441,7 +442,7 @@ final class InstallerController extends ControllerBase {
             'type' => MessengerInterface::TYPE_ERROR,
           ],
         ));
-        $this->logger->error($message);
+        Error::logException($this->logger, $e);
       }
     }
     $this->installState->deleteAll();
-- 
GitLab