diff --git a/src/Activator/ModuleActivator.php b/src/Activator/ModuleActivator.php index 6edee6617b58d394e07810e2b5837a76335a011d..b6731d8d498e8d6cf196051bbca438f293e0e0cb 100644 --- a/src/Activator/ModuleActivator.php +++ b/src/Activator/ModuleActivator.php @@ -25,8 +25,8 @@ final class ModuleActivator implements InstructionsInterface, TasksInterface { public function __construct( private readonly ModuleInstallerInterface $moduleInstaller, - private readonly ModuleExtensionList $moduleList, - private readonly ModuleHandlerInterface $moduleHandler, + private ModuleExtensionList $moduleList, + private ModuleHandlerInterface $moduleHandler, private readonly FileUrlGeneratorInterface $fileUrlGenerator, private readonly RequestStack $requestStack, private readonly AccountInterface $currentUser, @@ -36,7 +36,7 @@ final class ModuleActivator implements InstructionsInterface, TasksInterface { * {@inheritdoc} */ public function getStatus(Project $project): ActivationStatus { - if (array_key_exists($project->machineName, $this->moduleList->getAllInstalledInfo())) { + if ($this->moduleHandler->moduleExists($project->machineName)) { return ActivationStatus::Active; } elseif (array_key_exists($project->machineName, $this->moduleList->getAllAvailableInfo())) { @@ -57,6 +57,14 @@ final class ModuleActivator implements InstructionsInterface, TasksInterface { */ public function activate(Project $project): null { $this->moduleInstaller->install([$project->machineName]); + + // The container has changed, so we need to reload the module handler and + // module list from the global service wrapper. + // @phpstan-ignore-next-line + $this->moduleHandler = \Drupal::moduleHandler(); + // @phpstan-ignore-next-line + $this->moduleList = \Drupal::service(ModuleExtensionList::class); + return NULL; } diff --git a/src/Activator/RecipeActivator.php b/src/Activator/RecipeActivator.php index 2cff5fc002cbf64b84585f84d386d91cf8a5f2c4..bee7183790cd29bd411ec0d7886a49aaf5c26020 100644 --- a/src/Activator/RecipeActivator.php +++ b/src/Activator/RecipeActivator.php @@ -68,6 +68,8 @@ final class RecipeActivator implements InstructionsInterface, EventSubscriberInt */ public function getStatus(Project $project): ActivationStatus { $path = $this->getPath($project); + // Ensure we're getting the most up-to-date information. + $this->state->resetCache(); if (in_array($path, $this->state->get(static::STATE_KEY, []), TRUE)) { return ActivationStatus::Active; diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php index 0bd3f6b92f1d3dd8b8b1b8bb735ab2155bdadef3..161f4628a4528de320aaf12009b07fb9a2291d42 100644 --- a/src/Controller/InstallerController.php +++ b/src/Controller/InstallerController.php @@ -17,7 +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\project_browser\RefreshProjectsCommand; use Drupal\system\SystemManager; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -436,6 +436,7 @@ final class InstallerController extends ControllerBase { */ public function activate(Request $request): AjaxResponse { $response = new AjaxResponse(); + $activated_projects = []; $projects = $request->getPayload()->get('projects') ?? []; if ($projects) { @@ -445,6 +446,7 @@ final class InstallerController extends ControllerBase { assert(is_array($projects)); foreach ($projects as $project_id) { $this->installState->setState($project_id, 'activating'); + // $project_id is fully qualified and has the form `SOURCE_ID/LOCAL_ID`. [$source_id] = explode('/', $project_id, 2); try { @@ -455,10 +457,7 @@ final class InstallerController extends ControllerBase { $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)); + $activated_projects[] = $this->normalizer->normalize($project, context: ['source' => $source_id]); } catch (\Throwable $e) { $message = $e->getMessage(); @@ -472,7 +471,7 @@ final class InstallerController extends ControllerBase { } } $this->installState->deleteAll(); - return $response; + return $response->addCommand(new RefreshProjectsCommand($activated_projects)); } /** diff --git a/src/RefreshProjectCommand.php b/src/RefreshProjectsCommand.php similarity index 50% rename from src/RefreshProjectCommand.php rename to src/RefreshProjectsCommand.php index c6fab42a027e98558d6776fab416d2840b257364..d3e99d39e2027c272803bcefbc7e390875608d74 100644 --- a/src/RefreshProjectCommand.php +++ b/src/RefreshProjectsCommand.php @@ -7,12 +7,12 @@ namespace Drupal\project_browser; use Drupal\Core\Ajax\CommandInterface; /** - * An AJAX command to refresh a particular project in the Svelte app. + * An AJAX command to refresh projects in the Svelte app. */ -final class RefreshProjectCommand implements CommandInterface { +final class RefreshProjectsCommand implements CommandInterface { public function __construct( - private readonly array $project, + private readonly array $projects, ) {} /** @@ -20,8 +20,8 @@ final class RefreshProjectCommand implements CommandInterface { */ public function render(): array { return [ - 'command' => 'refresh_project', - 'project' => $this->project, + 'command' => 'refresh_projects', + 'projects' => $this->projects, ]; } diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js index 2dc635852ab161656e8e26b7c0bd72abc61f7084..cecd5d94d1a117891778ff9a5cfcbc6634c4108f 100644 Binary files a/sveltejs/public/build/bundle.js and b/sveltejs/public/build/bundle.js differ diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map index b43efeff6ef60e0e41ec035178f8fc9cb8272d0c..a012e1af66e538537ad02c48022de757983f9f89 100644 Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ diff --git a/sveltejs/src/ProjectBrowser.svelte b/sveltejs/src/ProjectBrowser.svelte index 77f62ce984900b3df81b8992e528bbc91c62c656..d126cd6d1985e9dc93b719ab5050e71a4c42eb4d 100644 --- a/sveltejs/src/ProjectBrowser.svelte +++ b/sveltejs/src/ProjectBrowser.svelte @@ -8,7 +8,8 @@ import { numberFormatter } from './util'; import { updated, installList } from './InstallListProcessor'; import MediaQuery from './MediaQuery.svelte'; - import { BASE_URL, FULL_MODULE_PATH, MAX_SELECTIONS } from './constants'; + import { FULL_MODULE_PATH, MAX_SELECTIONS } from './constants'; + import QueryManager from './QueryManager'; const { Drupal, drupalSettings } = window; const { announce } = Drupal; @@ -23,6 +24,8 @@ sortBy, } = drupalSettings.project_browser.instances[id]; + const queryManager = new QueryManager(); + const filters = writable({}); Object.entries(filterDefinitions).forEach(([key, definition]) => { $filters[key] = definition.value; @@ -43,11 +46,17 @@ setContext('mediaQueryValues', mediaQueryValues); let rowsCount = 0; - let data; let rows = []; const pageIndex = 0; // first row const preferredView = writable('Grid'); + // Set up a callback function that will be called whenever project data + // is refreshed or loaded from the backend. + queryManager.subscribe((projects) => { + // `rows` is reactive, so just update it to trigger a re-render. + rows = projects; + }); + let loading = true; let toggleView = 'Grid'; preferredView.subscribe((value) => { @@ -62,42 +71,15 @@ let searchComponent; /** - * Load data from the backend. + * Load data from QueryManager. * * @return {Promise<void>} * Empty promise that resolves on content load.* */ async function load() { loading = true; - - // Encode the current filter values as URL parameters. - const searchParams = new URLSearchParams(); - Object.entries($filters).forEach(([key, value]) => { - if (typeof value === 'boolean') { - value = Number(value).toString(); - } - searchParams.set(key, value); - }); - searchParams.set('page', $page); - searchParams.set('limit', $pageSize); - searchParams.set('sort', $sort); - searchParams.set('source', source); - - const url = `${BASE_URL}project-browser/data/project?${searchParams.toString()}`; - - const res = await fetch(url); - if (res.ok) { - data = await res.json(); - rows = data.list; - rowsCount = data.totalResults; - - if (data.error && data.error.length) { - new Drupal.Message().add(data.error, { type: 'error' }); - } - } else { - rows = []; - rowsCount = 0; - } + await queryManager.load($filters, $page, $pageSize, $sort, source); + rowsCount = queryManager.count; loading = false; } @@ -216,7 +198,7 @@ <div class="pb-layout__header"> <div class="pb-search-results"> - {#if data && source === data.pluginId} + {#if rowsCount} {Drupal.formatPlural( rowsCount, `${numberFormatter.format(1)} Result`, @@ -266,7 +248,7 @@ </div> </div> {#key $updated} - {#each rows as row, index (row)} + {#each rows as row (row.id)} <Project {toggleView} project={row} /> {/each} {/key} diff --git a/sveltejs/src/QueryManager.js b/sveltejs/src/QueryManager.js new file mode 100644 index 0000000000000000000000000000000000000000..5267398b5067102d6a2e5dac778b87decfad54c5 --- /dev/null +++ b/sveltejs/src/QueryManager.js @@ -0,0 +1,132 @@ +import { get, writable } from 'svelte/store'; +import { BASE_URL } from './constants'; + +// This is the single source of truth for all projects that have been loaded from +// the backend. It is keyed by fully qualified project ID, and shared by all +// QueryManager instances. +const cache = writable({}); + +function getFromCache (projects) { + const cacheData = get(cache); + return projects + .map(id => cacheData[id]) + .filter(item => typeof item === 'object'); +} + +function updateProjectsInCache (projects) { + // Use `.update()` so that all subscribers (i.e., individual QueryManager + // instances) will be notified and receive the latest project data. + cache.update((cacheData) => { + projects.forEach((project) => { + cacheData[project.id] = project; + }); + return cacheData; + }); +} + +// Allow cached projects to be updated via AJAX. +Drupal.AjaxCommands.prototype.refresh_projects = (ajax, { projects }) => { + updateProjectsInCache(projects); +}; + +/** + * Handles fetching and temporarily caching project data from the backend. + * + * This implements a volatile, centralized caching mechanism, ensuring that + * all instances of the Project Browser on a single page share a consistent + * source of truth for project data. + * + * The cache lives in memory and is reset upon page reload. + */ +export default class { + constructor () { + // A list of project IDs that were returned by the last query. These are + // only the project IDs; the most current data for each of them is stored + // in the static cache. + this.list = []; + // The subscribers that are listening for changes in the projects. + this.subscribers = []; + // The total (i.e., not paginated) number of results returned by the most + // recent query. + this.count = 0; + + // Whenever the cache changes, we want to notify our subscribers about any + // changes to the projects we have most recently loaded. + cache.subscribe(() => { + const projects = getFromCache(this.list); + + this.subscribers.forEach((callback) => { + callback(projects); + }); + }); + + this.lastQueryParams = null; + } + + subscribe (callback) { + const index = this.subscribers.length; + this.subscribers.push(callback); + + // The store contract requires us to immediately call the new subscriber. + callback(getFromCache(this.list)); + + // The store contract requires us to return an unsubscribe function. + return () => { + this.subscribers.splice(index, 1); + }; + } + + /** + * Fetch projects from the backend and store them in memory. + * + * @param {Object} filters - The filters to apply in the request. + * @param {Number} page - The current page number. + * @param {Number} pageSize - Number of items per page. + * @param {String} sort - Sorting method. + * @param {String} source - Data source. + * @return {Promise<Object>} - The list of project objects. + */ + async load(filters, page, pageSize, sort, source) { + this.list = []; + this.count = 0; + + // Encode the current filter values as URL parameters. + const searchParams = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (typeof value === 'boolean') { + value = Number(value).toString(); + } + searchParams.set(key, value); + }); + searchParams.set('page', page); + searchParams.set('limit', pageSize); + searchParams.set('sort', sort); + searchParams.set('source', source); + + const queryString = searchParams.toString(); + + if (this.lastQueryParams === queryString) { + return; + } + + this.lastQueryParams = queryString; + const res = await fetch( + `${BASE_URL}project-browser/data/project?${queryString}`, + ); + if (!res.ok) { + return; + } + + const data = await res.json(); + if (data.error && data.error.length) { + new Drupal.Message().add(data.error, { type: 'error' }); + } + + data.list.forEach((project) => { + this.list.push(project.id); + }); + this.count = data.totalResults; + + updateProjectsInCache(data.list); + } +} diff --git a/tests/modules/project_browser_test/src/TestActivator.php b/tests/modules/project_browser_test/src/TestActivator.php index f10a5e9cde147c10e39c5550de02c1c643598af2..8a8bb74d3174fd516092290a9af2724d362d0f76 100644 --- a/tests/modules/project_browser_test/src/TestActivator.php +++ b/tests/modules/project_browser_test/src/TestActivator.php @@ -40,7 +40,12 @@ class TestActivator implements ActivatorInterface { * {@inheritdoc} */ public function getStatus(Project $project): ActivationStatus { - if ($project->machineName === 'pinky_brain') { + $activated_projects = $this->state->get("test activator", []); + + if (in_array($project->id, $activated_projects, TRUE)) { + return ActivationStatus::Active; + } + elseif ($project->machineName === 'pinky_brain') { return ActivationStatus::Present; } return ActivationStatus::Absent; @@ -50,9 +55,9 @@ class TestActivator implements ActivatorInterface { * {@inheritdoc} */ public function activate(Project $project): null { - $log_message = $this->state->get("test activator", []); - $log_message[] = "$project->title was activated!"; - $this->state->set("test activator", $log_message); + $activated_projects = $this->state->get("test activator", []); + $activated_projects[] = $project->id; + $this->state->set("test activator", $activated_projects); return NULL; } diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php index 68480eb72e0a9cabad73cd2e9411187292a585be..55769e6d47ff1a7e4e7cbd0ac85a7b6ef39e917e 100644 --- a/tests/src/Functional/InstallerControllerTest.php +++ b/tests/src/Functional/InstallerControllerTest.php @@ -648,4 +648,30 @@ class InstallerControllerTest extends BrowserTestBase { $this->assertSession()->addressEquals('/admin/modules/browse/drupal_core'); } + /** + * Tests activating multiple core modules in a single request. + */ + public function testActivateMultipleModules(): void { + $this->config('project_browser.admin_settings') + ->set('enabled_sources', ['drupal_core']) + ->save(); + + // Prime the project cache. + $this->container->get(EnabledSourceHandler::class) + ->getProjects('drupal_core'); + + $response = $this->getPostResponse( + Url::fromRoute('project_browser.activate'), + [ + 'projects' => implode(',', [ + 'drupal_core/language', + 'drupal_core/content_translation', + 'drupal_core/locale', + ]), + ], + ); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(3, substr_count((string) $response->getBody(), '"status":"active"')); + } + } diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php index c29e717673d2b023cd96712cbabd598706794a63..f5afc262a60a8d2d2aa0d32e1458d3bc3d710415 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php @@ -80,9 +80,10 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $this->drupalGet('admin/modules/browse/project_browser_test_mock'); $this->installProject('Cream cheese on a bagel'); - // The activator in project_browser_test should have logged a message. + // The activator in project_browser_test should have logged the unqualified + // project ID. // @see \Drupal\project_browser_test\TestActivator - $this->assertContains('Cream cheese on a bagel was activated!', $this->container->get(StateInterface::class)->get('test activator')); + $this->assertContains('cream_cheese', $this->container->get(StateInterface::class)->get('test activator')); } /** @@ -176,6 +177,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { * @covers ::unlock */ public function testCanBreakStageWithMissingProjectBrowserLock(): void { + TestActivator::handle('drupal/cream_cheese'); + // Start install begin. $this->drupalGet('admin/modules/project_browser/install-begin', [ 'query' => ['source' => 'project_browser_test_mock'], @@ -203,6 +206,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { * @covers ::unlock */ public function testCanBreakLock(): void { + TestActivator::handle('drupal/cream_cheese'); + // Start install begin. $this->drupalGet('admin/modules/project_browser/install-begin', [ 'query' => ['source' => 'project_browser_test_mock'], @@ -238,6 +243,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { * Confirm that a status check warning allows download and install. */ public function testPackageManagerWarningAllowsDownloadInstall(): void { + TestActivator::handle('drupal/cream_cheese'); + // @see \Drupal\project_browser_test\TestInstallReadiness $this->container->get(StateInterface::class) ->set('project_browser_test.simulated_result_severity', SystemManager::REQUIREMENT_WARNING); @@ -281,12 +288,13 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $this->waitForProjectToBeInstalled($cream_cheese); $this->waitForProjectToBeInstalled($kangaroo); - // The activator in project_browser_test should have logged a message. + // The activator in project_browser_test should have logged the unqualified + // project IDs. // @see \Drupal\project_browser_test\TestActivator $activated = $this->container->get(StateInterface::class) ->get('test activator'); - $this->assertContains('Cream cheese on a bagel was activated!', $activated); - $this->assertContains('Kangaroo was activated!', $activated); + $this->assertContains('cream_cheese', $activated); + $this->assertContains('kangaroo', $activated); } /** diff --git a/tests/src/Kernel/ModuleActivatorTest.php b/tests/src/Kernel/ModuleActivatorTest.php index 1977ea6ad40ffd7a0d8c4e52206d770dc3eb2c4b..ceac2d066ec8e98fb702a11fcd2cd992fb6a93e6 100644 --- a/tests/src/Kernel/ModuleActivatorTest.php +++ b/tests/src/Kernel/ModuleActivatorTest.php @@ -26,6 +26,7 @@ class ModuleActivatorTest extends KernelTestBase { protected static $modules = [ 'block', 'breakpoint', + 'help', 'project_browser', 'user', ]; @@ -49,6 +50,13 @@ class ModuleActivatorTest extends KernelTestBase { */ protected function setUp(): void { $this->mockModuleHandler = $this->createMock(ModuleHandlerInterface::class); + $this->mockModuleHandler->expects($this->atLeastOnce()) + ->method('moduleExists') + ->willReturnMap(array_map( + fn (string $module_name): array => [$module_name, TRUE], + static::$modules, + )); + parent::setUp(); $this->config('project_browser.admin_settings') @@ -107,12 +115,6 @@ class ModuleActivatorTest extends KernelTestBase { * [false] */ public function testHelpLinksAreExposed(bool $implements_hook_help): void { - $this->enableModules(['help']); - - $this->mockModuleHandler->expects($this->atLeastOnce()) - ->method('moduleExists') - ->with('help') - ->willReturn(TRUE); $this->mockModuleHandler->expects($this->atLeastOnce()) ->method('hasImplementations') ->with('help', 'breakpoint')