diff --git a/project_browser.libraries.yml b/project_browser.libraries.yml index 1716ee1fdc00efacd3b257e75f4fb2def7498c55..6915d4612decb5fa776b404513823b63874398a7 100644 --- a/project_browser.libraries.yml +++ b/project_browser.libraries.yml @@ -15,6 +15,8 @@ svelte: - core/drupal.message - core/once - project_browser/project_browser + # This is included to support dropdown feature. + - core/drupal.dropbutton project_browser: css: diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js index 86164c2f79e64779de5091ca5fa7fb7410e9da42..bd7e99307d6b01a8521602dc50258ba96ddf5183 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 e2cb7317713d25d6380d7e3b939502e3c0dd10e5..f331147d9ab69fc4396616004277cc0bd59f21f3 100644 Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ diff --git a/sveltejs/src/Project/ActionButton.svelte b/sveltejs/src/Project/ActionButton.svelte index 01f7429a56b605126b2ba50ebda983e69d2f4d16..9e7c5704e357b8acf34e80cf4cba53d134fdad1f 100644 --- a/sveltejs/src/Project/ActionButton.svelte +++ b/sveltejs/src/Project/ActionButton.svelte @@ -1,19 +1,21 @@ <script> import { PACKAGE_MANAGER, MAX_SELECTIONS } from '../constants'; import { openPopup, getCommandsPopupMessage } from '../popup'; - import ProjectButtonBase from './ProjectButtonBase.svelte'; import ProjectStatusIndicator from './ProjectStatusIndicator.svelte'; - import ProjectIcon from './ProjectIcon.svelte'; import LoadingEllipsis from './LoadingEllipsis.svelte'; + import DropButton from './DropButton.svelte'; + import ProjectButtonBase from './ProjectButtonBase.svelte'; import { processInstallList, addToInstallList, installList, removeFromInstallList, } from '../InstallListProcessor'; + import ProjectIcon from './ProjectIcon.svelte'; // eslint-disable-next-line import/no-mutable-exports,import/prefer-default-export export let project; + let InstallListFull; const { Drupal } = window; const processMultipleProjects = MAX_SELECTIONS === null || MAX_SELECTIONS > 1; @@ -52,6 +54,9 @@ <ProjectStatusIndicator {project} statusText={Drupal.t('Installed')}> <ProjectIcon type="installed" /> </ProjectStatusIndicator> + {#if project.tasks.length > 0} + <DropButton tasks={project.tasks} /> + {/if} {:else} <span> {#if PACKAGE_MANAGER} diff --git a/sveltejs/src/Project/DropButton.svelte b/sveltejs/src/Project/DropButton.svelte new file mode 100644 index 0000000000000000000000000000000000000000..16451c01772f98f37b1da2879c645acf50435d44 --- /dev/null +++ b/sveltejs/src/Project/DropButton.svelte @@ -0,0 +1,76 @@ +<script> + // eslint-disable-next-line import/prefer-default-export + export let tasks = []; + + // Toggle the dropdown visibility for the clicked drop button + const toggleDropdown = (event) => { + const wrapper = event.currentTarget.closest('.dropbutton-wrapper'); + const isOpen = wrapper.classList.contains('open'); + + // Close all open dropdowns first + document.querySelectorAll('.dropbutton-wrapper.open').forEach((el) => { + el.classList.remove('open'); + }); + + if (!isOpen) { + wrapper.classList.add('open'); + } + }; + + // Handle keydown for closing the dropdown with Escape + const handleKeyDown = (event) => { + // Query the DOM for getting the only opened dropbutton. + const openDropdown = document.querySelector('.dropbutton-wrapper.open'); + if (!openDropdown) return; + + // If there are no items in the dropdown, exit early + if (!openDropdown.querySelectorAll('.secondary-action a').length) return; + + const toggleButton = openDropdown.querySelector('.dropbutton__toggle'); + if (event.key === 'Escape') { + openDropdown.classList.remove('open'); + toggleButton.focus(); + } + }; + + // Close the dropdown if clicked outside + const closeDropdownOnOutsideClick = (event) => { + document.querySelectorAll('.dropbutton-wrapper.open').forEach((wrapper) => { + if (!wrapper.contains(event.target)) { + wrapper.classList.remove('open'); + } + }); + }; + document.addEventListener('click', closeDropdownOnOutsideClick); + document.addEventListener('keydown', handleKeyDown); +</script> + +<div class="dropbutton-wrapper dropbutton-multiple" data-once="dropbutton"> + <div class="dropbutton-widget"> + <ul class="dropbutton dropbutton--extrasmall dropbutton--multiple"> + <li class="dropbutton__item dropbutton-action"> + <a href={tasks[0].url} on:click={() => {}} class="pb__action_button"> + {tasks[0].text} + </a> + </li> + + {#if tasks.length > 1} + <li class="dropbutton-toggle"> + <button + type="button" + class="dropbutton__toggle" + on:click={toggleDropdown} + > + <span class="visually-hidden">List additional actions</span> + </button> + </li> + + {#each tasks.slice(1) as task} + <li class="dropbutton__item dropbutton-action secondary-action"> + <a href={task.url}>{task.text}</a> + </li> + {/each} + {/if} + </ul> + </div> +</div> diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php index a0c09d22ee35e22484f63c6761bbd4852fa0246a..d0a0698622102ec4899a80cf531b22d14ddc9c4f 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Drupal\Tests\project_browser\FunctionalJavascript; +use Behat\Mink\Element\NodeElement; +use Drupal\Core\Extension\ModuleInstallerInterface; use Drupal\Core\Recipe\Recipe; use Drupal\Core\State\StateInterface; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; @@ -60,7 +62,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $this->installState = $install_state; $this->config('project_browser.admin_settings') - ->set('enabled_sources', ['project_browser_test_mock']) + ->set('enabled_sources', ['project_browser_test_mock', 'drupal_core', 'recipes']) ->set('allow_ui_install', TRUE) ->set('max_selections', 1) ->save(); @@ -383,4 +385,89 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase { $this->assertTrue($cream_cheese->hasButton('Install Cream cheese on a bagel')); } + /** + * Tests the drop button actions for a project. + */ + public function testDropButtonActions(): void { + $this->container->get(ModuleInstallerInterface::class)->install([ + 'config_translation', + 'contact', + 'content_translation', + 'help', + ]); + $this->rebuildContainer(); + + $this->drupalGet('admin/modules/browse/recipes'); + $this->svelteInitHelper('css', '.pb-projects-list'); + + $card = $this->waitForProject('Admin theme'); + $card->pressButton('Install'); + $this->waitForProjectToBeInstalled($card); + // Now assert that the dropdown button does not appear when + // we don't have any follow-up actions. + $this->assertNull($this->assertSession()->waitForElementVisible('css', '.dropbutton .secondary-action a')); + + $this->drupalGet('admin/modules/browse/drupal_core'); + $this->svelteInitHelper('css', '.pb-project.pb-project--list'); + $this->inputSearchField('contact', TRUE); + $this->assertElementIsVisible('css', ".search__search-submit")->click(); + $card = $this->waitForProject('Contact'); + $card->pressButton('List additional actions'); + $this->assertChildElementIsVisible($card, 'css', '.dropbutton .secondary-action a'); + + $available_actions = []; + foreach ($card->findAll('css', '.dropbutton .dropbutton-action a') as $item) { + $available_actions[$item->getText()] = $item->getAttribute('href') ?? ''; + } + + // Assert expected dropdown actions exist and point to the correct places. + $this->assertStringEndsWith('/admin/structure/contact', $available_actions['Configure']); + $this->assertStringEndsWith('/admin/help/contact', $available_actions['Help']); + $this->assertStringContainsString('/project-browser/uninstall/contact', $available_actions['Uninstall']); + + // Ensure that dropdown menus are mutually exclusive. + $this->inputSearchField('translation', TRUE); + $this->assertElementIsVisible('css', ".search__search-submit")->click(); + $project1 = $this->waitForProject('Content Translation'); + $project2 = $this->waitForProject('Configuration Translation'); + + // Ensure that an open dropdown closes when you click outside of it. + $project1->pressButton('List additional actions'); + $this->assertChildElementIsVisible($project1, 'css', '.dropbutton .secondary-action a'); + $project2->click(); + $this->assertFalse($project1->find('css', '.dropbutton .secondary-action')?->isVisible()); + + // Ensure that there can only be one open dropdown at a time. + $project2->pressButton('List additional actions'); + $this->assertChildElementIsVisible($project2, 'css', '.dropbutton .secondary-action a'); + $project1->pressButton('List additional actions'); + $this->assertChildElementIsVisible($project1, 'css', '.dropbutton .secondary-action a'); + $this->assertFalse($project2->find('css', '.dropbutton .secondary-action')?->isVisible()); + + // Ensure that we can close an open dropdown by clicking the button again. + $project1->pressButton('List additional actions'); + $this->assertFalse($project1->find('css', '.dropbutton .secondary-action')?->isVisible()); + } + + /** + * Waits for a child of a particular element, to be visible. + * + * @param \Behat\Mink\Element\NodeElement $parent + * An element that (presumably) contains children. + * @param string $selector + * The selector (e.g., `css`, `xpath`, etc.) to use to find a child element. + * @param mixed $locator + * The locator to pass to the selector engine. + * @param int $timeout + * (optional) How many seconds to wait for the child element to appear. + * Defaults to 10. + */ + private function assertChildElementIsVisible(NodeElement $parent, string $selector, mixed $locator, int $timeout = 10): void { + $is_visible = $parent->waitFor( + $timeout, + fn (NodeElement $parent) => $parent->find($selector, $locator)?->isVisible(), + ); + $this->assertTrue($is_visible); + } + } diff --git a/tests/src/Nightwatch/Tests/keyboardTest.js b/tests/src/Nightwatch/Tests/keyboardTest.js index b8d83d0c3a78391dd4cc4554b63c53523d51e721..ad2daa59b2ea3ea2b017aaa4c58d923ff9bc9f46 100644 --- a/tests/src/Nightwatch/Tests/keyboardTest.js +++ b/tests/src/Nightwatch/Tests/keyboardTest.js @@ -1,6 +1,8 @@ const delayInMilliseconds = 100; const filterKeywordSearch = '#pb-text'; const filterDropdownSelector = '.pb-filter__multi-dropdown'; +const dropButtonSelector = 'button.dropbutton__toggle'; +const dropButtonItemSelector = 'ul.dropbutton li.secondary-action a'; module.exports = { '@tags': ['project_browser'], @@ -36,6 +38,52 @@ module.exports = { return this.actions().sendKeys(browser.Keys.ESCAPE); } browser.drupalLoginAsAdmin(() => { + // We are enabling some modules in order to test the follow-up + // actions for some already installed modules in drupal core. + browser + .drupalRelativeURL('/admin/modules') + .click('[name="modules[package_manager][enable]"]') + .click('[name="modules[contact][enable]"]') + .click('[name="modules[help][enable]"]') + .submitForm('input[type="submit"]') + .waitForElementVisible( + '.system-modules-confirm-form input[value="Continue"]', + ) + .submitForm('input[value="Continue"]') + .waitForElementVisible('.system-modules', 10000); + browser + .drupalRelativeURL('/admin/config/development/project_browser') + .waitForElementVisible( + '[data-drupal-selector="edit-allow-ui-install"]', + delayInMilliseconds, + ) + .click('[data-drupal-selector="edit-allow-ui-install"]') + + // Wait for the select element and enable it + .waitForElementVisible( + '[data-drupal-selector="edit-enabled-sources-drupal-core-status"]', + delayInMilliseconds, + ) + .execute( + (selector) => { + document.querySelector(selector).removeAttribute('disabled'); + }, + ['[data-drupal-selector="edit-enabled-sources-drupal-core-status"]'], + ) + + .click( + '[data-drupal-selector="edit-enabled-sources-drupal-core-status"]', + ) + .click( + '[data-drupal-selector="edit-enabled-sources-drupal-core-status"] option[value="enabled"]', + ) + + // Click the Save Configuration button + .waitForElementVisible( + '[data-drupal-selector="edit-submit"]', + delayInMilliseconds, + ) + .click('[data-drupal-selector="edit-submit"]'); // Open project browser. browser .drupalRelativeURL('/admin/modules/browse/project_browser_test_mock') @@ -169,6 +217,70 @@ module.exports = { 'Assert that no filter lozenge is shown.', ); + browser + .drupalRelativeURL('/admin/modules/browse/drupal_core') + .waitForElementVisible('h1', delayInMilliseconds) + .assert.textContains('h1', 'Browse projects') + .waitForElementVisible('#aaa_update_test_title'); + + browser + .setValue('#pb-text', 'contact') + .waitForElementVisible('button.search__search-submit', 5000) + .execute(() => + document.querySelector('button.search__search-submit').click(), + ) + .pause(1000) + .waitForElementVisible('#contact_title', 1000); + + // Directly focus on the security icon. + browser + .waitForElementVisible('.pb-project__status-icon-btn', 1000) + .execute( + (selector) => { + const el = document.querySelector(selector); + if (el) { + el.focus(); + } + }, + ['.pb-project__status-icon-btn'], + ); + // Navigate to maintenance icon. + browser.perform(sendTabKey).pause(1000); + // Navigate to installed button. + browser.perform(sendTabKey).pause(1000); + // Navigate to Installed button. + browser.perform(sendTabKey).pause(1000); + // Navigate to dropdown button. + browser.perform(sendTabKey).pause(1000); + assertFocus(dropButtonSelector, 'Assert dropbutton has focus.'); + + // Press space to open the dropbutton menu. + browser.perform(sendSpaceKey).pause(1000); + browser.assert.visible( + dropButtonItemSelector, + 'Assert dropbutton menu is visible.', + ); + + // Navigate to first dropbutton item using keyboard. + browser.perform(sendTabKey).pause(delayInMilliseconds); + assertFocus( + 'ul.dropbutton li.secondary-action a', + 'Assert first dropbutton item has focus.', + ); + // Navigate to second dropbutton item using keyboard. + browser.perform(sendTabKey).pause(delayInMilliseconds); + assertFocus( + 'ul.dropbutton li.secondary-action a', + 'Assert second dropbutton item has focus.', + ); + + // Press escape to close the dropbutton menu. + browser.perform(sendEscapeKey).pause(1000); + assertFocus( + dropButtonSelector, + 'Assert focus returns to dropbutton after closing.', + ); + // Close out test. browser.drupalLogAndEnd({ onlyOnError: false }); });