From 9393f883a6bbc097bbb38ca405b979d5052e1746 Mon Sep 17 00:00:00 2001
From: Adam G-H <32250-phenaproxima@users.noreply.drupalcode.org>
Date: Mon, 10 Feb 2025 22:16:34 +0000
Subject: [PATCH] Issue #3505188 by phenaproxima, tim.plunkett: Simplify
 EnabledSourceHandler by removing any activation-related logic from it

---
 src/Controller/InstallerController.php        |  64 +++-----------
 .../ProjectBrowserEndpointController.php      |  34 ++++----
 src/EnabledSourceHandler.php                  |  80 ++++++------------
 src/InstallState.php                          |  12 ++-
 src/ProjectBrowser/Project.php                |  20 +----
 src/ProjectBrowser/ProjectsResultsPage.php    |  18 +++-
 src/Routing/ProjectBrowserRoutes.php          |  19 -----
 sveltejs/public/build/bundle.js               | Bin 275927 -> 274113 bytes
 sveltejs/public/build/bundle.js.map           | Bin 253970 -> 253240 bytes
 sveltejs/src/ProjectBrowser.svelte            |  30 +++----
 .../Functional/EnabledSourceHandlerTest.php   |  45 +++-------
 .../Functional/InstallerControllerTest.php    |  20 ++---
 .../ProjectBrowserInstallerUiTest.php         |   3 +-
 13 files changed, 107 insertions(+), 238 deletions(-)

diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index f91671d3f..1a79c2964 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -13,7 +13,6 @@ use Drupal\project_browser\ActivatorInterface;
 use Drupal\project_browser\ComposerInstaller\Installer;
 use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\InstallState;
-use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser_test\Plugin\ProjectBrowserSource\ProjectBrowserTestMock;
 use Drupal\system\SystemManager;
 use Psr\Log\LoggerInterface;
@@ -31,27 +30,6 @@ final class InstallerController extends ControllerBase {
 
   use StatusCheckTrait;
 
-  /**
-   * No require or install in progress for a given module.
-   *
-   * @var int
-   */
-  protected const STATUS_IDLE = 0;
-
-  /**
-   * A staging install in progress for a given module.
-   *
-   * @var int
-   */
-  protected const STATUS_REQUIRING_PROJECT = 1;
-
-  /**
-   * A core install in progress for a given project.
-   *
-   * @var int
-   */
-  protected const STATUS_INSTALLING_PROJECT = 2;
-
   /**
    * The endpoint successfully returned the expected data.
    *
@@ -119,28 +97,6 @@ final class InstallerController extends ControllerBase {
     }
   }
 
-  /**
-   * Returns the status of the project in the temp store.
-   *
-   * @param \Drupal\project_browser\ProjectBrowser\Project $project
-   *   A project whose status to report.
-   *
-   * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   Information about the project's require/install status.
-   */
-  public function inProgress(Project $project): JsonResponse {
-    $project_state = $this->installState->getStatus($project);
-    $return = ['status' => self::STATUS_IDLE];
-
-    if ($project_state !== NULL) {
-      $return['status'] = ($project_state === 'requiring' || $project_state === 'applying')
-        ? self::STATUS_REQUIRING_PROJECT
-        : self::STATUS_INSTALLING_PROJECT;
-      $return['phase'] = $project_state;
-    }
-    return new JsonResponse($return);
-  }
-
   /**
    * Provides a JSON response for a given error.
    *
@@ -364,10 +320,10 @@ final class InstallerController extends ControllerBase {
    */
   public function require(Request $request, string $stage_id): JsonResponse {
     $package_names = [];
-    foreach ($request->toArray() as $project) {
-      $project = $this->enabledSourceHandler->getStoredProject($project);
-      if ($project->source === 'project_browser_test_mock') {
-        $source = $this->enabledSourceHandler->getCurrentSources()[$project->source] ?? NULL;
+    foreach ($request->toArray() as $project_id) {
+      $project = $this->enabledSourceHandler->getStoredProject($project_id);
+      if (str_starts_with($project_id, 'project_browser_test_mock/')) {
+        $source = $this->enabledSourceHandler->getCurrentSources()['project_browser_test_mock'] ?? NULL;
         if ($source === NULL) {
           return new JsonResponse(['message' => "Cannot download $project->id from any available source"], 500);
         }
@@ -376,7 +332,7 @@ final class InstallerController extends ControllerBase {
           return new JsonResponse(['message' => "$project->machineName is not safe to add because its security coverage has been revoked"], 500);
         }
       }
-      $this->installState->setState($project, 'requiring');
+      $this->installState->setState($project_id, 'requiring');
       $package_names[] = $project->packageName;
     }
     try {
@@ -400,7 +356,7 @@ final class InstallerController extends ControllerBase {
    */
   public function apply(string $stage_id): JsonResponse {
     foreach (array_keys($this->installState->toArray()) as $project_id) {
-      $this->installState->setState($this->enabledSourceHandler->getStoredProject($project_id), 'applying');
+      $this->installState->setState($project_id, 'applying');
     }
     try {
       $this->installer->claim($stage_id)->apply();
@@ -464,12 +420,12 @@ final class InstallerController extends ControllerBase {
    *   Status message.
    */
   public function activate(Request $request): Response {
-    foreach ($request->toArray() as $project) {
-      $project = $this->enabledSourceHandler->getStoredProject($project);
-      $this->installState->setState($project, 'activating');
+    foreach ($request->toArray() as $project_id) {
+      $this->installState->setState($project_id, 'activating');
       try {
+        $project = $this->enabledSourceHandler->getStoredProject($project_id);
         $response = $this->activator->activate($project);
-        $this->installState->setState($project, 'installed');
+        $this->installState->setState($project_id, 'installed');
       }
       catch (\Throwable $e) {
         return $this->errorResponse($e, 'project install');
diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index 0c985be25..5b1c4299c 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -3,6 +3,7 @@
 namespace Drupal\project_browser\Controller;
 
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\project_browser\ActivatorInterface;
 use Drupal\project_browser\EnabledSourceHandler;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -14,14 +15,9 @@ use Symfony\Component\HttpFoundation\Response;
  */
 final class ProjectBrowserEndpointController extends ControllerBase {
 
-  /**
-   * Constructor for endpoint controller.
-   *
-   * @param \Drupal\project_browser\EnabledSourceHandler $enabledSource
-   *   The enabled project browser source.
-   */
   public function __construct(
     private readonly EnabledSourceHandler $enabledSource,
+    private readonly ActivatorInterface $activator,
   ) {}
 
   /**
@@ -30,33 +26,37 @@ final class ProjectBrowserEndpointController extends ControllerBase {
   public static function create(ContainerInterface $container): static {
     return new static(
       $container->get(EnabledSourceHandler::class),
+      $container->get(ActivatorInterface::class),
     );
   }
 
   /**
-   * Responds to GET requests.
-   *
-   * Returns a list of bundles for specified entity.
+   * Returns a list of projects that match a query.
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The request.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   Typically a project listing.
+   *   A list of projects.
+   *
+   * @see \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage
    */
   public function getAllProjects(Request $request): JsonResponse {
-    $id = $request->query->get('id');
-    if ($id) {
-      assert(is_string($id));
-      return new JsonResponse($this->enabledSource->getStoredProject($id));
-    }
-
     $current_sources = $this->enabledSource->getCurrentSources();
     $query = $this->buildQuery($request);
     if (!$current_sources || empty($query['source'])) {
       return new JsonResponse([], Response::HTTP_ACCEPTED);
     }
-    return new JsonResponse($this->enabledSource->getProjects($query['source'], $query));
+
+    // The activator is the source of truth about the status of the project with
+    // respect to the current site, and it is responsible for generating
+    // the activation instructions or commands.
+    $result = $this->enabledSource->getProjects($query['source'], $query);
+    foreach ($result->list as $project) {
+      $project->status = $this->activator->getStatus($project);
+      $project->commands = $this->activator->getInstructions($project);
+    }
+    return new JsonResponse($result);
   }
 
   /**
diff --git a/src/EnabledSourceHandler.php b/src/EnabledSourceHandler.php
index 07933be68..1f3ad4119 100644
--- a/src/EnabledSourceHandler.php
+++ b/src/EnabledSourceHandler.php
@@ -25,7 +25,6 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
   public function __construct(
     private readonly ConfigFactoryInterface $configFactory,
     private readonly ProjectBrowserSourceManager $pluginManager,
-    private readonly ActivatorInterface $activator,
     private readonly KeyValueFactoryInterface $keyValueFactory,
   ) {}
 
@@ -105,10 +104,10 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
    * @param array $query
    *   (optional) The query to pass to the specified source.
    *
-   * @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage[]
-   *   The result of the query, keyed by source plugin ID.
+   * @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage
+   *   The result of the query.
    */
-  public function getProjects(string $source_id, array $query = []): array {
+  public function getProjects(string $source_id, array $query = []): ProjectsResultsPage {
     // Cache only exact query, down to the page number.
     $cache_key = 'query:' . md5(Json::encode($query));
 
@@ -118,35 +117,28 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
     // If $results is an array, it's a set of arguments to ProjectsResultsPage,
     // with a list of project IDs that we expect to be in the data store.
     if (is_array($results)) {
-      $results[1] = array_map($this->getStoredProject(...), $results[1]);
-      $results = new ProjectsResultsPage(...$results);
+      $results[1] = $storage->getMultiple($results[1]);
+      $results[1] = array_values($results[1]);
+      return new ProjectsResultsPage(...$results);
     }
-    else {
-      $results = $this->doQuery($source_id, $query);
-
-      foreach ($results->list as $project) {
-        // Prefix the local project ID with the source plugin ID, so we can
-        // look it up unambiguously.
-        $project->id = $source_id . '/' . $project->id;
-
-        $storage->setIfNotExists($project->id, $project);
-        // Add activation data to the project. This is volatile, which is why we
-        // never store it.
-        $this->getActivationData($project);
-      }
-      // If there were no query errors, store the results as a set of arguments
-      // to ProjectsResultsPage.
-      if (empty($results->error)) {
-        $storage->set($cache_key, [
-          $results->totalResults,
-          array_column($results->list, 'id'),
-          $results->pluginLabel,
-          $source_id,
-          $results->error,
-        ]);
-      }
+    $results = $this->doQuery($source_id, $query);
+    // Cache all the projects individually so they can be loaded by
+    // ::getStoredProject().
+    foreach ($results->list as $project) {
+      $storage->setIfNotExists($project->id, $project);
+    }
+    // If there were no query errors, store the results as a set of arguments
+    // to ProjectsResultsPage.
+    if (empty($results->error)) {
+      $storage->set($cache_key, [
+        $results->totalResults,
+        array_column($results->list, 'id'),
+        $results->pluginLabel,
+        $source_id,
+        $results->error,
+      ]);
     }
-    return [$source_id => $results];
+    return $results;
   }
 
   /**
@@ -174,35 +166,17 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
    * Looks up a previously stored project by its ID.
    *
    * @param string $id
-   *   The project ID. See ::getProjects() for where this is set.
+   *   The fully qualified project ID, in the form `SOURCE_ID/LOCAL_ID`.
    *
    * @return \Drupal\project_browser\ProjectBrowser\Project
-   *   The project object, with activation status and commands added.
+   *   The project object.
    *
    * @throws \RuntimeException
    *   Thrown if the project is not found in the non-volatile data store.
    */
   public function getStoredProject(string $id): Project {
-    [$source_id] = explode('/', $id, 2);
-    $project = $this->keyValue($source_id)->get($id) ?? throw new \RuntimeException("Project '$id' was not found in non-volatile storage.");
-    $this->getActivationData($project);
-    return $project;
-  }
-
-  /**
-   * Adds activation data to a project object.
-   *
-   * @param \Drupal\project_browser\ProjectBrowser\Project $project
-   *   The project object.
-   */
-  private function getActivationData(Project $project): void {
-    // The project's activator is the source of truth about the status of
-    // the project with respect to the current site.
-    $project->status = $this->activator->getStatus($project);
-    // The activator is responsible for generating the instructions.
-    $project->commands = $this->activator->getInstructions($project);
-    // Give the front-end the ID of the source plugin that exposed this project.
-    [$project->source] = explode('/', $project->id, 2);
+    [$source_id, $local_id] = explode('/', $id, 2);
+    return $this->keyValue($source_id)->get($local_id) ?? throw new \RuntimeException("Project '$id' was not found in non-volatile storage.");
   }
 
   /**
diff --git a/src/InstallState.php b/src/InstallState.php
index acc81e53f..dea603390 100644
--- a/src/InstallState.php
+++ b/src/InstallState.php
@@ -43,11 +43,9 @@ final class InstallState {
    *   Example return value:
    *   [
    *     'project_id1' => [
-   *       'source' => 'source_plugin_id1',
    *       'status' => 'requiring',
    *     ],
    *     'project_id2' => [
-   *       'source' => 'source_plugin_id2',
    *       'status' => 'installing',
    *     ],
    *     '__timestamp' => 1732086755,
@@ -64,20 +62,20 @@ final class InstallState {
   /**
    * Sets project state and initializes a timestamp if not set.
    *
-   * @param \Drupal\project_browser\ProjectBrowser\Project $project
-   *   The project object containing the ID and source of the project.
+   * @param string $project_id
+   *   The fully qualified ID of the project, in the form `SOURCE_ID/LOCAL_ID`.
    * @param string|null $status
    *   The installation status to set for the project, or NULL if no status.
    *   The status can be any arbitrary string, depending on the context
    *   or use case.
    */
-  public function setState(Project $project, ?string $status): void {
+  public function setState(string $project_id, ?string $status): void {
     $this->keyValue->setIfNotExists('__timestamp', $this->time->getRequestTime());
     if (is_string($status)) {
-      $this->keyValue->set($project->id, ['source' => $project->source, 'status' => $status]);
+      $this->keyValue->set($project_id, ['status' => $status]);
     }
     else {
-      $this->keyValue->delete($project->id);
+      $this->keyValue->delete($project_id);
     }
   }
 
diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php
index 953f997cb..46b6b9346 100644
--- a/src/ProjectBrowser/Project.php
+++ b/src/ProjectBrowser/Project.php
@@ -19,24 +19,9 @@ class Project implements \JsonSerializable {
   /**
    * A persistent ID for this project in non-volatile storage.
    *
-   * This property is internal and should be ignored by source plugins.
-   *
    * @var string
-   *
-   * @see \Drupal\project_browser\EnabledSourceHandler::getProjects()
-   */
-  public string $id;
-
-  /**
-   * The ID of the source plugin which exposed this project.
-   *
-   * This property is internal and should be ignored by source plugins.
-   *
-   * @var string
-   *
-   * @see \Drupal\project_browser\EnabledSourceHandler::getProjects()
    */
-  public string $source;
+  public readonly string $id;
 
   /**
    * The status of this project in the current site.
@@ -56,7 +41,7 @@ class Project implements \JsonSerializable {
    *
    * @see \Drupal\project_browser\ActivatorInterface::getInstructions()
    */
-  public string|Url|null $commands;
+  public string|Url|null $commands = NULL;
 
   /**
    * The project type (e.g., module, theme, recipe, or something else).
@@ -232,7 +217,6 @@ class Project implements \JsonSerializable {
       'selector_id' => $this->getSelectorId(),
       'commands' => $commands,
       'id' => $this->id,
-      'source' => $this->source,
     ];
   }
 
diff --git a/src/ProjectBrowser/ProjectsResultsPage.php b/src/ProjectBrowser/ProjectsResultsPage.php
index 3ce74890f..7e89a60d5 100644
--- a/src/ProjectBrowser/ProjectsResultsPage.php
+++ b/src/ProjectBrowser/ProjectsResultsPage.php
@@ -7,7 +7,7 @@ use Drupal\Component\Assertion\Inspector;
 /**
  * One page of search results from a query.
  */
-class ProjectsResultsPage {
+class ProjectsResultsPage implements \JsonSerializable {
 
   /**
    * Constructor for project browser results page.
@@ -34,4 +34,20 @@ class ProjectsResultsPage {
     assert(Inspector::assertAllObjects($list, Project::class));
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function jsonSerialize(): array {
+    // Fully qualify the project IDs before sending them to the front end.
+    $map = function (Project $project): array {
+      return [
+        'id' => $this->pluginId . '/' . $project->id,
+      ] + $project->jsonSerialize();
+    };
+
+    return [
+      'list' => array_map($map, $this->list),
+    ] + get_object_vars($this);
+  }
+
 }
diff --git a/src/Routing/ProjectBrowserRoutes.php b/src/Routing/ProjectBrowserRoutes.php
index 2ca6b7859..c72bd9c4e 100644
--- a/src/Routing/ProjectBrowserRoutes.php
+++ b/src/Routing/ProjectBrowserRoutes.php
@@ -143,25 +143,6 @@ final class ProjectBrowserRoutes implements ContainerInjectionInterface {
         'methods' => ['POST'],
       ]
     );
-    $routes['project_browser.module.install_in_progress'] = new Route(
-      '/admin/modules/project_browser/install_in_progress/{source}/{id}',
-      [
-        '_controller' => InstallerController::class . '::inProgress',
-        '_title' => 'Install in progress',
-        'project' => NULL,
-      ],
-      [
-        '_permission' => 'administer modules',
-        '_custom_access' => InstallerController::class . '::access',
-      ],
-      [
-        'parameters' => [
-          'project' => [
-            'project_browser.project' => ['source', 'id'],
-          ],
-        ],
-      ]
-    );
     $routes['project_browser.install.unlock'] = new Route(
       '/admin/modules/project_browser/install/unlock',
       [
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index a5cccf50992e64ca199944035b6570b47ace8a1b..e6485015a05bd9e1a27627069385f94533919d71 100644
GIT binary patch
delta 1048
zcmaiyYfRf!7{>FS_p}r!P{vld4gZV;DVI`8X@LP@%9hQ|Xl9Ht2D1vC>B7aeY?v4$
z8VxFF&_BuZVMY_@ht0%L;_#?(;uIG)=L)z5G6SM9amr<&E`u?N|6)k|=C^a6Jn!ZF
z&NDe@J2!3XIzzwKV)b|0qe|*1B@Vk5s0Oy9bOifvkX0@2rpt*?tK{w%@8ZeZl#fHV
zI1MESsR+{@WW&r~Y{zgi-KCrl(}6V1Tf`RCdze~YLU5EDRNWj2fqNGy4HHqetJVeD
zXN?`xF}6zmc=I6bP_Nvkres_{DN?)FEkdnZI5>>HWj5<KYAkr8k-fs>!@wwK3r`ik
z8)XN&>PWKN=RwZ}z7Jy;_*3@y8j<@lJJirc)`U@^CNJ?jBz)!Qyvz!NCYgbKQO?_N
zF8v|0(Pt8Qh(uY7P1}m=Q4XjF$9SI5<!2K<Hc2}&dMOpb34Rn`P4FYg_?~l7KgByx
zuZc?RKThwVXN;@S`WThr^W&6&hg_l<KV0Dfa;cuHEKPZCPj#8gp*@=yJghbQJN#OH
zz7}pj7HSG;wY9Yyk{Y<i*94WTz)xI}#AVeU3`PvBXBOEh^p-CzD7_`jwpevxsn*o-
z%;WAxqYY?=b1a(*N@h48wpnBMvtG6$$IE7Xv&<>N+laDak%r%vc?7O%F43Pqr(yPW
zO2Tk8Th)O-c^BzxU&mu%kP{H;=Oq38^L9k)joWNDr|2J!SpW4qZ!)%&7XHsBy+0a=
zA}85~E-RT)ddHyX`bETHY%gbFslmW)ndJ;@-_3XHr_X02`YJtvJu93V+m-H6I=*;~
z?)gtmYmX74iz`NQ+SVx>uA4@Sr5-BO7rwRN(z=nzzN5xC*IQzz8d~Kd!rY+2Aodh|
z7AIVK$x5d5Rc?Z9UgI?>TvbDU`L_v8W_iE4qQd8@@TwuR92ew8cf9nO%H2M1r5cHs
zZxaHEvSHISm?$+iOvB?M7tf80Y?YECUnKJAkwv>YWs~`2d}cINenM1%)sGAsAEe1C
zbz-ZmA@X5rn><8rRb`hyGv*birpIzotNA<q!454H2s@92+uI5pj-C1oSGFPNDkUVw
zkULv~;f@V49n)FTDq|wnGo_a741_}Mp;)#yw3;lLvAr$OsWpU-J?(FCJ`f0p{Z9o7
c98Q08b3x3a+Owo)qhg$%qylxeNM<Jd18=u=U;qFB

delta 1450
zcmZWpTWnNS6wN;Sw4IsGv@?CtQYg1jYp0Au@1--HKBj;`AR@Nl3qsm5PA^n!J7wmU
zheQL$#1B8HxrY7YGsYNv03YIs(WEsJQ$%S&i~$sjAsT^}DG&_^t>+Gu`k0@|IeYDW
z)>?b-`^8W0A1=9fe+<8cL8A#TEEB#)aAy=~6`OUVO^-x7+M=;;L*HSvsbw0i8HI9k
zZ-p)NegK_fY&jgyl!@}XfWEo`#gueGBl*@sJ80sSweWkc{NJ?+o_mp+mxGPQ!mvn$
zF99&BUk+iheH3b)R;bWQ-$#4bLK)2&haAy&6V_zW&P|X@w<{sX>^zY9QnsCmY&$;O
zPfeq;*u+I>WC4xd9>iHJP)+@V=rt$K+s(eKZt@Rdx_PC+M$SqIvg%rDyaWqT3u$y^
z5SOwxm9(SyDF(C_>I>jZv-xngnDI3#OjZ=N-{2d-LMom97KMfFJB|(3Q;Wyoe(JV_
zW(}cY(I}(`hftRo@!k+#hP&V*`tSs9p@AE~sqiE|YF$5b5=(iT>aC}7&eqYYZPH3v
zYBqJPhfL}*z(YTOhb7d!QND3>HPl%FmD)Z>2klPhKJuQzgAf#_Pa#iJ7OHis=2dph
zdwP?Si1o&!x)KhDmG+1cc_Px;tABC^&#-w_b!wFbvKpC+Es4h?JC#Uc8c2~Hpis0O
zJ^R|3q)^Q4X6Jy7DPh`~#FDI3waNub)Y!2>+W6XKET@cPY%U#`#Rh3aXKotyK^~pE
z$!zpO67&C?@n#a|nvEmtY4$kIq;ro!HXXXaR#D#^R$`7GvzhMmA#y(}V{=Mb47F-i
zoc$34E~pWni@1W(Dh{sv1(lYx6#ctow3d4E9LHH^Q-21HwBkCbppqdh+qV_m^hYuC
zc)B7+v_nr!kq@;{X${JGx{OEY;7aH)_n*z9#rt6%DKWH}Z@uFZ4P)p7LI-T6(*sas
zessi1?hn94pFJWqj{GT056oab8haelsUnGssQCd%qs6UYo4+jH+Y{;ZDe>6$#L`%A
zx8b|n8Q-+^l8Y7y2B|fI#X0|1M5|p8w`RA1Tn0Qz{wxTP+@k=UGo)|(v)~8~kE4f4
zEqmU@!j!L4PX@JKMNi6Hb6_O%4%_6*Fx$K}vYK{pXZ2#mHJlIBk--XS(hd2PWn)fC
zcF57Y&?QHC_&TPsP>`hMMIw0}|Kdyyh|wE(J1s5cI$fN^hiq!Fwko8BLWk@42?il@
zBCknT7phe?8cp89UE)jRr7)dQOp}~@@1+TU8dnfPqRGbl0fOeCu>x_QgO`JpI1!TD
z)Z^q%(UZwv1*oC%!<a`!F8-^lQ#Ynf6xUpQB|t4TWb=(sEnd&&zhI91P9+SbOHU;9
z?#+5!2`jWDkLTIz9g6(f?RMIm%V)59hq>oe{#@-JO_9*1r?%)(!@td1okWGz7%vS=
zIF)=44ySeAaA(U}=vXdaV7qs^nah<mQzyS3kH_Lld3lQKzi<7Wl9tgSw{eP({T^3;
Yq`f`$=v0bm&Etyuj*a5^GQQODHz0%ZZU6uP

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index 523888a5249060df4794f8a62742063dc487937c..547b743ececee6a2160a1145beeab700720cfe06 100644
GIT binary patch
delta 811
zcmbVKUr1A76z6{D&NbIu(5(DJT@;sW7t_Ry-9~)(&epALu7pqoxop-}%h{dlj}#OI
z5g}QT2cJ@+hk{^IaWhQqAw|so;7c#dqO3=u*9iLVCRVR~4~OrZ-|zg+?>l_Arku~7
zJ1<`0o{+IBW2uUa@i^}xf?ZFZNeO--s`YAk-Agv(sFw&uHC?)%)CIRY4=j60DP}yx
zWvkJm(Q1=yREnh2;f^28F-&GEnbsp+g5RH4tLaIkkHx#y=!K4MtbNS6u(O1eA@3vQ
z*zRL`yH;*~T>j1#VfGQ{ygKJ2#+(lvmmKG0B_7hmAf-W3K~0A;tX(2jbc{n|fmf!1
zMj20aKnX_1p~8|>C~d%u1GeJ832@@wjbOpZ4>o1k1vX2H!L2yz0Cyg)cmXMh1rDJ9
zGowsSz*DUKLhOb<0Sz3sd?kE=N>g$|!HFACKll;^V`d75c<wZ6ui&^vrQx$O4(x`~
zdZw(3EYN)DGH=XuvzhJ}n^u9y3ZRNAW+$P#Ag<8EY(U90DE1KTm1DBlOm_tE(;e{G
zB`HJ`fqkqJ$Kh$Hw8nBt80#KD#o#w$O{la*F$YmmTUGuKFO(``n9^|0sr2_r%cehb
zL<rYSf(s9pLm>`qf-+-d2KqU!54X*N$aNbXv+x%9BeZFcv3DLW^A^^s9Kv7kq1v9?
ziewgaTzp81H;C##3(G+<8Q--+@s>79+y3lQl2qE%C+8o@x+sp^hV{5}0oLMcjTB-g
zMAlfT)J7Hd7jAxlVjEjP(Bubkk?CQ}5b$_!0X$r<@n!)kZLnQ##dH?VV^0>Yk-BF5
UnT3An3AAHo7D|o#AK@hT8*N(s5&!@I

delta 1210
zcmZvaU2GIp6vs37?5yl=>+;oTLEvtYy6p~Ig5cV08hdAEEnBi(u~mZ5kZE_=4`+9l
z*<DJd#2SqTqA~TsLt^BK_cjqSN`sJq!Ndow`UEc+V|-Do@kxEqduQ8jO`M0DIp_Y*
z|NPFG`Sg1DgInR*x1_7&vprpwS*JC-;Y^w}IzW$)Cr?x;70H}YH<;rX)9(Bpa@+UR
zq+O}iY1ypR%u3#LxVU0YQQc`&4J)4EX(Mk&dy-h{CDGUW`Y5ATkr&vfXuV)k<BU<X
zj0wwJYgV^up0}wwcFCM<wu_72;%Nb8C&X?1Rdc!)6+}HApU|xFagr6)8N+IrLbq$Z
z(6Oh)XhMIyX*`Y}_mZ6()}qB;LSI~s;-=%+4(;y#Pen5uxA^S!LJvK+N=jQBi5t0`
z$GGa`(il0}o-vKd!a7=`<^?Yc*7GeJG19r+#ZxP@(}95|vZsqztwz3BN$1X^@`>~~
zp6n#s@tbGKPJFVTJb^)R@VDnM_M@~Le+rTIcV_yDJJS!F-oS-liQhM^YSTD(9VX<W
z#`FXZE|YDzc@etgn#R~c4C|1P^ZZpqwoE$l;bRbxr+7Su8~GJ}y9lSDq#wfS71)NA
z8*mM8{Xs%*<OUp+{4tdcTN?hJhK^uaW$dsr#MmfC_QDnn-GOcl|4c&u8ox=X|ErQT
z{N)lv@V6g0>wdLQ#mGf?8kgsY>|XjDuKJ|cFmoG9vaPWrbzDkti19XfM9K-gE<t|u
z5GL+`CM<lxEIX?fR3*b=c=JyZYfC1xtf~+5L|Mnve-RqUGRuo!5@8e{^2TymV`D?O
zF#(-8`{=599AEjHY~IvRc?U68R^eM=N(v)7>=VnC-D_XKMM>(z!MmVHj$6J9_h82;
zOARZdd<~+A>%}1|lSf(V1$Xc(c+<DvVA}KRG-=_UVJstrnvxY^t1ip5s)k?QgQ!25
zOtT>e2mj%}!QLeZ;>ZDh?0A<1aQ-{+W19(qV6$7AY5h&%oI$qW_yP%`Jwi5wR%rPB
z*>y%^3uJ4@F~*KHr}TgYaY@6uSzf;@400%?92Vc0<EC5L?JHRi-5WxnCyy|#q~rH*
zldbsv3F1ebCGDPzl){@|!vRv!ao=U=aDz+G9)M@mG5qL0%;1Oj;Q}d)VB`V3C)M5g
I2XI097a1>q4gdfE

diff --git a/sveltejs/src/ProjectBrowser.svelte b/sveltejs/src/ProjectBrowser.svelte
index 24f1410ba..46ff0b192 100644
--- a/sveltejs/src/ProjectBrowser.svelte
+++ b/sveltejs/src/ProjectBrowser.svelte
@@ -45,8 +45,6 @@
   let rowsCount = 0;
   let data;
   let rows = [];
-  let sources = [];
-  let dataArray = [];
   const pageIndex = 0; // first row
   const preferredView = writable('Grid');
 
@@ -89,16 +87,12 @@
 
     const res = await fetch(url);
     if (res.ok) {
-      const messenger = new Drupal.Message();
       data = await res.json();
-      // A list of the available sources to get project data.
-      sources = Object.keys(data);
-      dataArray = Object.values(data);
-      rows = data[source].list;
-      rowsCount = data[source].totalResults;
+      rows = data.list;
+      rowsCount = data.totalResults;
 
-      if (data[source].error && data[source].error.length) {
-        messenger.add(data[source].error, { type: 'error' });
+      if (data.error && data.error.length) {
+        new Drupal.Message().add(data.error, { type: 'error' });
       }
     } else {
       rows = [];
@@ -227,15 +221,13 @@
 
       <div class="pb-layout__header">
         <div class="pb-search-results">
-          {#each dataArray as dataValue}
-            {#if source === dataValue.pluginId}
-              {Drupal.formatPlural(
-                rowsCount,
-                `${numberFormatter.format(1)} Result`,
-                `${numberFormatter.format(rowsCount)} Results`,
-              )}
-            {/if}
-          {/each}
+          {#if data && source === data.pluginId}
+            {Drupal.formatPlural(
+              rowsCount,
+              `${numberFormatter.format(1)} Result`,
+              `${numberFormatter.format(rowsCount)} Results`,
+            )}
+          {/if}
         </div>
 
         {#if matches}
diff --git a/tests/src/Functional/EnabledSourceHandlerTest.php b/tests/src/Functional/EnabledSourceHandlerTest.php
index 6d44e2f63..6942a41c7 100644
--- a/tests/src/Functional/EnabledSourceHandlerTest.php
+++ b/tests/src/Functional/EnabledSourceHandlerTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\project_browser\Functional;
 
+use Drupal\project_browser\ActivationStatus;
 use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser_test\Plugin\ProjectBrowserSource\ProjectBrowserTestMock;
@@ -41,10 +42,10 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
    */
   public function testExceptionOnGetUnknownProject(): void {
     $this->expectException(\RuntimeException::class);
-    $this->expectExceptionMessage("Project 'unseen' was not found in non-volatile storage.");
+    $this->expectExceptionMessage("Project 'sight/unseen' was not found in non-volatile storage.");
 
     $this->container->get(EnabledSourceHandler::class)
-      ->getStoredProject('unseen');
+      ->getStoredProject('sight/unseen');
   }
 
   /**
@@ -53,43 +54,20 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
   public function testGetStoredProject(): void {
     $handler = $this->container->get(EnabledSourceHandler::class);
 
-    $projects = $handler->getProjects('project_browser_test_mock');
-    assert(!empty($projects));
-    $list = reset($projects)->list;
-    $this->assertNotEmpty($list);
-    $project = reset($list);
+    $project = $handler->getProjects('project_browser_test_mock')->list[0];
 
-    $project_again = $handler->getStoredProject($project->id);
+    $project_again = $handler->getStoredProject('project_browser_test_mock/' . $project->id);
     $this->assertNotSame($project, $project_again);
+    // Project::$status is a typed property and therefore must be initialized
+    // before it is accessed by jsonSerialize().
+    $project->status = ActivationStatus::Active;
+    $project_again->status = ActivationStatus::Active;
     $this->assertSame($project->jsonSerialize(), $project_again->jsonSerialize());
 
     // The activation status and commands should be set.
     $this->assertTrue(self::hasActivationData($project_again));
   }
 
-  /**
-   * Tests that projects are not stored with any activation data.
-   */
-  public function testProjectsAreStoredWithoutActivationData(): void {
-    // Projects returned from getProjects() should have their activation status
-    // and commands set.
-    $projects = $this->container->get(EnabledSourceHandler::class)
-      ->getProjects('project_browser_test_mock');
-    assert(!empty($projects));
-    $list = reset($projects)->list;
-    $this->assertNotEmpty($list);
-    $project = reset($list);
-    $this->assertTrue(self::hasActivationData($project));
-
-    // But if we pull the project directly from the data store, the `status` and
-    // `commands` properties should be uninitialized.
-    $project = $this->container->get('keyvalue')
-      ->get('project_browser:project_browser_test_mock')
-      ->get($project->id);
-    $this->assertInstanceOf(Project::class, $project);
-    $this->assertFalse(self::hasActivationData($project));
-  }
-
   /**
    * Tests that query results are not stored if there was an error.
    */
@@ -133,10 +111,9 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
    * Tests that the install profile is ignored by the drupal_core source.
    */
   public function testProfileNotListedByCoreSource(): void {
-    $projects = $this->container->get(EnabledSourceHandler::class)->getProjects('drupal_core');
-    $this->assertNotEmpty($projects);
+    $result = $this->container->get(EnabledSourceHandler::class)->getProjects('drupal_core');
     // Assert that the current install profile is not returned by the source.
-    $this->assertNotContains($this->profile, array_column(reset($projects)->list, 'machineName'));
+    $this->assertNotContains($this->profile, array_column($result->list, 'machineName'));
   }
 
 }
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 8ed41aa59..6d370ee5b 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -243,7 +243,7 @@ class InstallerControllerTest extends BrowserTestBase {
     ]);
     $expected_output = sprintf('{"phase":"create","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'project_browser_test_mock', 'requiring');
+    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'requiring');
   }
 
   /**
@@ -255,7 +255,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->drupalGet("/admin/modules/project_browser/install-apply/$this->stageId");
     $expected_output = sprintf('{"phase":"apply","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'project_browser_test_mock', 'applying');
+    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'applying');
   }
 
   /**
@@ -267,7 +267,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->drupalGet("/admin/modules/project_browser/install-post_apply/$this->stageId");
     $expected_output = sprintf('{"phase":"post apply","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'project_browser_test_mock', 'applying');
+    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'applying');
   }
 
   /**
@@ -279,7 +279,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->drupalGet("/admin/modules/project_browser/install-destroy/$this->stageId");
     $expected_output = sprintf('{"phase":"destroy","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'project_browser_test_mock', 'applying');
+    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'applying');
   }
 
   /**
@@ -469,7 +469,6 @@ class InstallerControllerTest extends BrowserTestBase {
     $assert_unlock_response($response, "The process for adding the project that was locked less than 1 minutes ago might still be in progress. Consider waiting a few more minutes before using [+unlock link].");
     $expected = [
       'project_browser_test_mock/awesome_module' => [
-        'source' => 'project_browser_test_mock',
         'status' => 'requiring',
       ],
     ];
@@ -593,21 +592,14 @@ class InstallerControllerTest extends BrowserTestBase {
    *
    * @param string $project_id
    *   The ID of the project being enabled.
-   * @param string $source
-   *   The project source.
-   * @param string $status
+   * @param string|null $status
    *   The install state.
    */
-  protected function assertInstallInProgress(string $project_id, string $source, ?string $status = NULL): void {
+  protected function assertInstallInProgress(string $project_id, ?string $status = NULL): void {
     $expect_install[$project_id] = [
-      'source' => $source,
       'status' => $status,
     ];
     $this->assertSame($expect_install, $this->getInstallState()->toArray());
-    $this->drupalGet("/admin/modules/project_browser/install_in_progress/$project_id");
-    $this->assertSame(sprintf('{"status":1,"phase":"%s"}', $status), $this->getSession()->getPage()->getContent());
-    $this->drupalGet('/admin/modules/project_browser/install_in_progress/project_browser_test_mock/metatag');
-    $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent());
   }
 
   /**
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index b3dadd9ce..daa07e2ad 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -349,8 +349,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    */
   private function chooseProjectToInstall(array $except_these_machine_names = [], string $source_id = 'project_browser_test_mock'): string {
     $handler = $this->container->get(EnabledSourceHandler::class);
-    $projects = $handler->getProjects($source_id);
-    $results = $projects[$source_id];
+    $results = $handler->getProjects($source_id);
 
     foreach ($results->list as $project) {
       if (in_array($project->machineName, $except_these_machine_names, TRUE)) {
-- 
GitLab