From e8fd07d7e4f49e82f64abbe2fe83b5c3396e9044 Mon Sep 17 00:00:00 2001 From: Tim Plunkett <git@plnktt.com> Date: Mon, 13 May 2024 17:18:20 -0400 Subject: [PATCH] Issue #3446092 by bnjmnm, phenaproxima: "View Commands" button assumes project is a DO module (and that a project has commands) --- .../ProjectBrowserSourceExample.php | 41 ++++ project_browser.libraries.yml | 1 + src/ProjectBrowser/Project.php | 25 +++ sveltejs/css/claro.css | 1 + sveltejs/public/build/bundle.js | Bin 338969 -> 340649 bytes sveltejs/public/build/bundle.js.map | Bin 311285 -> 311827 bytes sveltejs/src/Project/ActionButton.svelte | 2 +- sveltejs/src/ProjectBrowser.svelte | 2 +- sveltejs/src/popup.js | 209 ++++++++++-------- .../project_browser_test.info.yml | 2 +- .../ProjectBrowserUiTest.php | 16 +- 11 files changed, 195 insertions(+), 104 deletions(-) diff --git a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php index 75b1be393..c003e7b28 100644 --- a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php +++ b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php @@ -2,6 +2,7 @@ namespace Drupal\project_browser_source_example\Plugin\ProjectBrowserSource; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\project_browser\Plugin\ProjectBrowserSourceBase; use Drupal\project_browser\ProjectBrowser\Project; use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage; @@ -30,12 +31,15 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase { * The plugin implementation definition. * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack * The request from the browser. + * @param \Drupal\Core\Extension\ModuleExtensionList $moduleExtensionList + * The module extension list. */ public function __construct( array $configuration, $plugin_id, $plugin_definition, protected readonly RequestStack $requestStack, + protected ModuleExtensionList $moduleExtensionList, ) { parent::__construct($configuration, $plugin_id, $plugin_definition); } @@ -49,6 +53,7 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase { $plugin_id, $plugin_definition, $container->get('request_stack'), + $container->get('extension.list.module'), ); } @@ -155,6 +160,42 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase { // Images: Array of images using the same structure as $logo, above. images: [], ); + // @phpstan-ignore-next-line + $pb_path = $this->moduleExtensionList->getPath('project_browser'); + $projects[] = new Project( + id: $project_from_source['identifier'] . '2', + logo: $logo, + // Maybe the source won't have all fields, but we still need to + // populate the values of all the properties. + isCompatible: TRUE, + isMaintained: TRUE, + isCovered: TRUE, + isActive: TRUE, + starUserCount: 0, + projectUsageTotal: 0, + machineName: $project_from_source['unique_name'] . '2', + body: [ + 'summary' => $project_from_source['short_description'] . ' (different commands)', + 'value' => $project_from_source['long_description'] . ' (different commands)', + ], + title: 'A project with different commands', + // Status: 1 enabled / 0 disabled. + status: 1, + changed: $project_from_source['updated_at'], + created: $project_from_source['created_at'], + author: $author, + composerNamespace: $project_from_source['composer_namespace'], + categories: $categories, + // Images: Array of images using the same structure as $logo, above. + images: [], + type: 'different-commands', + commands: "<b>Steps to doing this thing</b> + <p>You can do it!</p> + <div class=\"command-box\"> + <input id=\"{$project_from_source['identifier']}-download-command\" value=\"composer require {$project_from_source['unique_name'] }\" readonly=\"\"> + <button data-copy-command><img src=\"/{$pb_path}/images/copy-icon.svg\" alt=\"Copy steps for {$project_from_source['identifier']}\"/></button> + </div>", + ); } // Return one page of results. The first parameter is the total number of diff --git a/project_browser.libraries.yml b/project_browser.libraries.yml index 351181ad8..af1f1f076 100644 --- a/project_browser.libraries.yml +++ b/project_browser.libraries.yml @@ -12,6 +12,7 @@ svelte: - core/drupal.debounce - core/drupal.dialog - core/drupal.announce + - core/once - project_browser/project_browser project_browser: diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php index 49ba0ca3f..74df556f3 100644 --- a/src/ProjectBrowser/Project.php +++ b/src/ProjectBrowser/Project.php @@ -4,6 +4,7 @@ namespace Drupal\project_browser\ProjectBrowser; use Drupal\Component\Utility\Html; use Drupal\Component\Utility\Unicode; +use Drupal\Component\Utility\Xss; /** * Defines a single Project. @@ -53,6 +54,26 @@ class Project implements \JsonSerializable { * Images of the project. * @param array $warnings * Warnings for the project. + * @param string $type + * The project type. Defaults to 'module:drupalorg' to indicate modules from + * D.O., but may be changed to anything else that could helpfully identify + * a project type. + * @param string|bool $commands + * When FALSE, the project browser UI will not provide a "View Commands" + * button for the project UNLESS the type 'module:drupalorg', in which case + * it displays Svelte-generated install instructions. + * When it is a string and NOT 'module:drupalorg', that string will become + * the contents of the "View Commands" popup. + * To include a paste-able command that includes a copy button, use this + * markup structure: + * @code + * <div class="command-box"> + * <input value="THE_COMMAND_TO_BE_COPIED" readonly="" /> + * <button data-copy-command> + * <img src="/PATH_TO_PROJECT_BROWSER/images/copy-icon.svg\" alt="ALT TEXT"/> + * </button> + * </div> + * @endcode */ public function __construct( public string $id, @@ -75,6 +96,8 @@ class Project implements \JsonSerializable { public array $categories = [], public array $images = [], public array $warnings = [], + public string $type = 'module:drupalorg', + public string|bool $commands = FALSE, ) { $this->setSummary($body); } @@ -135,6 +158,8 @@ class Project implements \JsonSerializable { 'changed' => $this->changed, 'created' => $this->created, 'selector_id' => $this->getSelectorId(), + 'type' => $this->type, + 'commands' => Xss::filter($this->commands, [...Xss::getAdminTagList(), 'input', 'button']), ]; } diff --git a/sveltejs/css/claro.css b/sveltejs/css/claro.css index d6bca4dd0..0adf3d560 100644 --- a/sveltejs/css/claro.css +++ b/sveltejs/css/claro.css @@ -32,6 +32,7 @@ padding-inline: 1rem 0.25rem; } .project-browser-popup .copied-action { + position: absolute; z-index: 1; right: -18px; width: fit-content; diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js index cdae5fdb3a9fb43b536867f2c4fcefbfd8fb3561..a6faced07b6d5205aa1ed7267371cc23a7871558 100644 GIT binary patch delta 3230 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 delta 2522 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 BM%(}Z diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map index bf947ccf70fa162466436c8fb9b1add946174626..e9d7a93e4517405cb95e552ee052a1f0303bbe05 100644 GIT binary patch delta 4672 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 delta 4313 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 diff --git a/sveltejs/src/Project/ActionButton.svelte b/sveltejs/src/Project/ActionButton.svelte index b71951961..e92b4627c 100644 --- a/sveltejs/src/Project/ActionButton.svelte +++ b/sveltejs/src/Project/ActionButton.svelte @@ -224,7 +224,7 @@ {showStatus} /> {/if} - {:else} + {:else if project.type === 'module:drupalorg' || project.commands} <ProjectButtonBase click={() => openPopup(getCommandsPopupMessage(project), project)} >{Drupal.t('View Commands')} diff --git a/sveltejs/src/ProjectBrowser.svelte b/sveltejs/src/ProjectBrowser.svelte index aeb73acc2..5de0db249 100644 --- a/sveltejs/src/ProjectBrowser.svelte +++ b/sveltejs/src/ProjectBrowser.svelte @@ -176,7 +176,7 @@ } await load($page); - const focus = document.getElementById(element); + const focus = element ? document.getElementById(element) : false; if (focus) { focus.focus(); $focusedElement = ''; diff --git a/sveltejs/src/popup.js b/sveltejs/src/popup.js index a327a7c12..c6d80261a 100644 --- a/sveltejs/src/popup.js +++ b/sveltejs/src/popup.js @@ -1,96 +1,117 @@ import { FULL_MODULE_PATH, ORIGIN_URL } from './constants'; // cspell:ignore dont +const { once, Drupal } = window; -export const copyCommand = (cmd, project) => { - const getCopyElements = () => { - const getCopyElement = (suffix) => document.querySelector(`#${project.project_machine_name}-${suffix}`) - const action = ['Download', 'Install'].includes(cmd) ? cmd.toLowerCase() : 'install-drush'; - return [ - getCopyElement(`${action}-command`), - getCopyElement(`copied-${action}`), - ] - } - const [copiedCommand, copyReceipt] = getCopyElements(); - - copiedCommand.select(); - // For mobile devices. - copiedCommand.setSelectionRange(0, 99999); - navigator.clipboard.writeText(copiedCommand.value); - copyReceipt.style.opacity = '1'; +/** + * Finds [data-copy-command] buttons and adds copy functionality to them. + */ +const enableCopyButtons = () => { setTimeout(() => { - copyReceipt.style.transition = 'opacity 0.3s'; - copyReceipt.style.opacity = '0'; - }, 1000); -}; + once('copyButton', '[data-copy-command]').forEach((copyButton) => { + // If clipboard is not supported (likely due to non-https), then hide the + // button and do not bother with event listeners + if (!navigator.clipboard) { + // copyButton.hidden = true; + // return; + } + copyButton.addEventListener('click', (e) => { + // The copy button must be contained in a div + const container = e.target.closest('div'); + // The only <input> within the parent dive should have its value set + // to the command that should be copied. + const input = container.querySelector('input'); + + // Make the input value the selected text + input.select() + input.setSelectionRange(0, 99999); + navigator.clipboard.writeText(input.value); + Drupal.announce(Drupal.t('Copied text to clipboard')); + + // Create a "receipt" that will visually show the text has been copied. + const receipt = document.createElement('div') + receipt.textContent = Drupal.t('Copied') + receipt.classList.add('copied-action') + receipt.style.opacity = '1'; + input.insertAdjacentElement('afterend', receipt) + // eslint-disable-next-line max-nested-callbacks + setTimeout(() => { + // Remove the receipt after 1 second. + receipt.remove() + }, 1000); + }) + }) + }) +} export const getCommandsPopupMessage = (project) => { - const download = Drupal.t('Download'); - const composerText = Drupal.t( - 'The !use_composer_open recommended way!close to download any Drupal module is with !get_composer_open Composer!close.', - { - '!close': '</a>', - '!use_composer_open': - '<a href="https://www.drupal.org/docs/develop/using-composer/using-composer-to-install-drupal-and-manage-dependencies#managing-contributed" target="_blank" rel="noreferrer noopener">', - '!get_composer_open': - '<a href="https://getcomposer.org/" target="_blank" rel="noopener noreferrer">', - }, - ); - const composerExistsText = Drupal.t( - "If you already manage your Drupal application dependencies with Composer, run the following from the command line in your application's Composer root directory", - ); - const infoText = Drupal.t('This will download the module to your codebase.'); - const composerDontWorkText = Drupal.t( - "Didn't work? !learn_open Learn how to troubleshoot Composer!close", - { - '!learn_open': - '<a href="https://getcomposer.org/doc/articles/troubleshooting.md" target="_blank" rel="noopener noreferrer">', - '!close': '</a>', - }, - ); - const downloadModuleText = Drupal.t( - 'If you cannot use Composer, you may !dl_manually_open download the module manually through your browser!close', - { - '!dl_manually_open': - '<a href="https://www.drupal.org/docs/user_guide/en/extend-module-install.html#s-using-the-administrative-interface" target="_blank" rel="noreferrer">', - '!close': '</a>', - }, - ); - const install = Drupal.t('Install'); - const installText = Drupal.t( - 'Go to the !module_page_open Extend page!close (admin/modules), check the box next to each module you wish to enable, then click the Install button at the bottom of the page.', - { - '!module_page_open': `<a href="${ORIGIN_URL}/admin/modules" target="_blank" rel="noopener noreferrer">`, - '!close': '</a>', - }, - ); - const drushText = Drupal.t( - 'Alternatively, you can use !drush_openDrush!close to install it via the command line', - { - '!drush_open': '<a href="https://www.drush.org/latest/" target="_blank" rel="noopener noreferrer">', - '!close': '</a>', - }, - ); - const installDrush = Drupal.t( - 'If Drush is not installed, this will add the tool to your codebase', - ); - const copied = Drupal.t('Copied!'); - const downloadAlt = Drupal.t('Copy the download command'); - const installAlt = Drupal.t('Copy the install command'); - const drushAlt = Drupal.t('Copy the install Drush command'); - const copyIcon = `${FULL_MODULE_PATH}/images/copy-icon.svg`; - const makeButton = (altText, action) => `<button id="${action}-btn"><img src="${copyIcon}" alt="${altText}"/></button> - <div id="${project.project_machine_name}-copied-${action}" class="copied-action">${copied}</div>` - const downloadCopyButton = navigator.clipboard ? makeButton(downloadAlt, 'download') : ''; - const installCopyButton = navigator.clipboard ? makeButton(installAlt, 'install') : ''; - const installDrushCopyButton = navigator.clipboard ? makeButton(drushAlt, 'install-drush') : ''; + // @todo move the message provided in this condition to the 'commands' + // property of the project definition. + if (project.type === 'module:drupalorg') { + const download = Drupal.t('Download'); + const composerText = Drupal.t( + 'The !use_composer_open recommended way!close to download any Drupal module is with !get_composer_open Composer!close.', + { + '!close': '</a>', + '!use_composer_open': + '<a href="https://www.drupal.org/docs/develop/using-composer/using-composer-to-install-drupal-and-manage-dependencies#managing-contributed" target="_blank" rel="noreferrer noopener">', + '!get_composer_open': + '<a href="https://getcomposer.org/" target="_blank" rel="noopener noreferrer">', + }, + ); + const composerExistsText = Drupal.t( + "If you already manage your Drupal application dependencies with Composer, run the following from the command line in your application's Composer root directory", + ); + const infoText = Drupal.t('This will download the module to your codebase.'); + const composerDontWorkText = Drupal.t( + "Didn't work? !learn_open Learn how to troubleshoot Composer!close", + { + '!learn_open': + '<a href="https://getcomposer.org/doc/articles/troubleshooting.md" target="_blank" rel="noopener noreferrer">', + '!close': '</a>', + }, + ); + const downloadModuleText = Drupal.t( + 'If you cannot use Composer, you may !dl_manually_open download the module manually through your browser!close', + { + '!dl_manually_open': + '<a href="https://www.drupal.org/docs/user_guide/en/extend-module-install.html#s-using-the-administrative-interface" target="_blank" rel="noreferrer">', + '!close': '</a>', + }, + ); + const install = Drupal.t('Install'); + const installText = Drupal.t( + 'Go to the !module_page_open Extend page!close (admin/modules), check the box next to each module you wish to enable, then click the Install button at the bottom of the page.', + { + '!module_page_open': `<a href="${ORIGIN_URL}/admin/modules" target="_blank" rel="noopener noreferrer">`, + '!close': '</a>', + }, + ); + const drushText = Drupal.t( + 'Alternatively, you can use !drush_openDrush!close to install it via the command line', + { + '!drush_open': '<a href="https://www.drush.org/latest/" target="_blank" rel="noopener noreferrer">', + '!close': '</a>', + }, + ); + const installDrush = Drupal.t( + 'If Drush is not installed, this will add the tool to your codebase', + ); + const downloadAlt = Drupal.t('Copy the download command'); + const installAlt = Drupal.t('Copy the install command'); + const drushAlt = Drupal.t('Copy the install Drush command'); + const copyIcon = `${FULL_MODULE_PATH}/images/copy-icon.svg`; + const makeButton = (altText, action) => `<button data-copy-command id="${action}-btn"><img src="${copyIcon}" alt="${altText}"/></button>` + const downloadCopyButton = makeButton(downloadAlt, 'download'); + const installCopyButton = makeButton(installAlt, 'install'); + const installDrushCopyButton = makeButton(drushAlt, 'install-drush'); - const div = document.createElement('div'); - div.classList.add('window'); - div.innerHTML = `<h3>1. ${download}</h3> + const div = document.createElement('div'); + div.classList.add('window'); + div.innerHTML = `<h3>1. ${download}</h3> <p>${composerText}</p> <p>${composerExistsText}:</p> <div class="command-box"> - <input id="${project.project_machine_name}-download-command" value="composer require ${project.composer_namespace}" readonly/> + <input value="composer require ${project.composer_namespace}" readonly/> ${downloadCopyButton} </div> @@ -101,14 +122,14 @@ export const getCommandsPopupMessage = (project) => { <p>${installText}</p> <p>${drushText}:</p> <div class="command-box"> - <input id="${project.project_machine_name}-install-command" value="drush pm:install ${project.project_machine_name}" readonly/> + <input value="drush pm:install ${project.project_machine_name}" readonly/> ${installCopyButton} </div> </div> <p>${installDrush}:</p> <div class="command-box"> - <input id="${project.project_machine_name}-install-drush-command" value="composer require drush/drush" readonly/> + <input value="composer require drush/drush" readonly/> ${installDrushCopyButton} </div> <style> @@ -118,21 +139,23 @@ export const getCommandsPopupMessage = (project) => { border: 1px solid; } </style>`; - if (navigator.clipboard) { - [['download', 'Download'], ['install', 'Install'], ['install-drush', 'Drush']].forEach(([id, command]) => { - div.querySelector(`#${id}-btn`).addEventListener('click', () => { - copyCommand(command, project); - }); - }); + enableCopyButtons(); + return div; + } + if (project.commands) { + const div = document.createElement('div'); + div.innerHTML = project.commands; + enableCopyButtons(); + return div; } - return div; + }; export const openPopup = (getMessage, project) => { const message = typeof getMessage === 'function' ? getMessage() : getMessage; const popupModal = Drupal.dialog(message, { title: project.title, - dialogClass: 'project-browser-popup', + classes: {'ui-dialog': 'project-browser-popup'}, width: '50rem', }); popupModal.showModal(); diff --git a/tests/modules/project_browser_test/project_browser_test.info.yml b/tests/modules/project_browser_test/project_browser_test.info.yml index ccb21531e..bdeba4dcb 100644 --- a/tests/modules/project_browser_test/project_browser_test.info.yml +++ b/tests/modules/project_browser_test/project_browser_test.info.yml @@ -1,6 +1,6 @@ name: Project Browser test type: module description: 'Support module for testing Project Browser.' -core_version_requirement: ^9 || ^10 +package: Testing dependencies: - project_browser:project_browser diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php index ba9a61eb3..174ef53b3 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php @@ -200,18 +200,18 @@ class ProjectBrowserUiTest extends WebDriverTestBase { $this->getSession()->executeScript('navigator.clipboard = true'); $this->assertTrue($assert_session->waitForText('By Hel Vetica')); $this->clickWithWait('#project-browser .project__action_button'); - $allowed_html_field = $assert_session->fieldExists('helvetica-download-command'); - $this->assertTrue($allowed_html_field->hasAttribute('readonly')); - $allowed_html_field = $assert_session->fieldExists('helvetica-install-command'); - $this->assertTrue($allowed_html_field->hasAttribute('readonly')); + $require_command = $assert_session->waitForElement('css', 'input[value="composer require drupal/helvetica"]'); + $this->assertTrue($require_command->hasAttribute('readonly')); + $install_command = $assert_session->waitForElement('css', 'input[value="drush pm:install helvetica"]'); + $this->assertTrue($install_command->hasAttribute('readonly')); // Tests alt text for copy command image. - $download_command = $page->find('css', '#download-btn img'); - $this->assertEquals('Copy the download command', $download_command->getAttribute('alt')); + $download_commands = $page->findAll('css', '.command-box img'); + $this->assertEquals('Copy the download command', $download_commands[0]->getAttribute('alt')); $install_command = $page->find('css', '#install-btn img'); - $this->assertEquals('Copy the install command', $install_command->getAttribute('alt')); + $this->assertEquals('Copy the install command', $download_commands[1]->getAttribute('alt')); $install_command = $page->find('css', '#install-drush-btn img'); - $this->assertEquals('Copy the install Drush command', $install_command->getAttribute('alt')); + $this->assertEquals('Copy the install Drush command', $download_commands[2]->getAttribute('alt')); } /** -- GitLab