diff --git a/app/javascript/packs/admin/users/memberIndex.js b/app/javascript/packs/admin/users/memberIndex.js new file mode 100644 index 000000000..b79b88342 --- /dev/null +++ b/app/javascript/packs/admin/users/memberIndex.js @@ -0,0 +1,107 @@ +import { openDropdown, closeDropdown } from '@utilities/dropdownUtils'; +import { copyToClipboard } from '@utilities/runtime'; + +// We present up to 50 users in the UI at once, and for performance reasons we don't want to add individual click listeners to each dropdown menu or inner menu item +// Instead we listen for click events anywhere in the table, and identify required actions based on data attributes of the target +document + .getElementById('member-index-content') + ?.addEventListener('click', ({ target }) => { + const { + dataset: { copyEmail, toggleDropdown }, + } = target; + + if (copyEmail) { + handleEmailCopy(copyEmail); + return; + } + + if (toggleDropdown) { + handleDropdownToggle({ + triggerElementId: target.getAttribute('id'), + dropdownContentId: toggleDropdown, + }); + return; + } + }); + +// We keep track of the currently opened dropdown to make sure we only ever have one open at a time +let currentlyOpenDropdownId; + +const handleDropdownToggle = ({ triggerElementId, dropdownContentId }) => { + const triggerButton = document.getElementById(triggerElementId); + + const isCurrentlyOpen = + triggerButton.getAttribute('aria-expanded') === 'true'; + + if (isCurrentlyOpen) { + closeDropdown({ triggerElementId, dropdownContentId }); + triggerButton.focus(); + currentlyOpenDropdownId = null; + } else { + closeCurrentlyOpenDropdown(); + openDropdown({ triggerElementId, dropdownContentId }); + currentlyOpenDropdownId = dropdownContentId; + } +}; + +/** + * Make sure any currently opened dropdown is closed + */ +const closeCurrentlyOpenDropdown = (focusTrigger = false) => { + if (!currentlyOpenDropdownId) { + return; + } + const triggerButton = document.querySelector( + `[aria-controls='${currentlyOpenDropdownId}']`, + ); + + closeDropdown({ + dropdownContentId: currentlyOpenDropdownId, + triggerElementId: triggerButton?.getAttribute('id'), + }); + + if (focusTrigger) { + triggerButton.focus(); + } +}; + +// We listen for key up events on this page to make sure users can close the dropdowns via keyboard +document.addEventListener('keyup', ({ key }) => { + if (key === 'Escape') { + closeCurrentlyOpenDropdown(true); + return; + } + if (key === 'Tab') { + // If we're not inside a dropdown any more, let's close it + const closestDropdown = document.activeElement.closest('.crayons-dropdown'); + if (!closestDropdown) { + closeCurrentlyOpenDropdown(); + } + } +}); + +/** + * Helper function to copy the given text to the clipboard and display a snackbar confirmation. + * + * @param {string} copyEmail The email to copy + */ +const handleEmailCopy = (copyEmail) => { + copyToClipboard(copyEmail) + .then(() => { + document.dispatchEvent( + new CustomEvent('snackbar:add', { + detail: { message: 'Copied to clipboard' }, + }), + ); + closeCurrentlyOpenDropdown(true); + }) + .catch(() => { + document.dispatchEvent( + new CustomEvent('snackbar:add', { + detail: { + message: 'Unable to copy the text. Try reloading the page', + }, + }), + ); + }); +}; diff --git a/app/javascript/utilities/dropdownUtils.js b/app/javascript/utilities/dropdownUtils.js index 4ab8bd402..b46387b7e 100644 --- a/app/javascript/utilities/dropdownUtils.js +++ b/app/javascript/utilities/dropdownUtils.js @@ -63,7 +63,7 @@ export const INTERACTIVE_ELEMENTS_QUERY = * @param {string} args.triggerElementId The id of the button which activates the dropdown * @param {string} args.dropdownContent The id of the dropdown content element */ -const openDropdown = ({ triggerElementId, dropdownContentId }) => { +export const openDropdown = ({ triggerElementId, dropdownContentId }) => { const dropdownContent = document.getElementById(dropdownContentId); const triggerElement = document.getElementById(triggerElementId); @@ -84,7 +84,11 @@ const openDropdown = ({ triggerElementId, dropdownContentId }) => { * @param {string} args.dropdownContent The id of the dropdown content element * @param {Function} args.onClose Optional function for any side-effects which should occur on dropdown close */ -const closeDropdown = ({ triggerElementId, dropdownContentId, onClose }) => { +export const closeDropdown = ({ + triggerElementId, + dropdownContentId, + onClose, +}) => { const dropdownContent = document.getElementById(dropdownContentId); if (!dropdownContent) { diff --git a/app/views/admin/users/_member_index.html.erb b/app/views/admin/users/_member_index.html.erb index a7420517e..2f64622ca 100644 --- a/app/views/admin/users/_member_index.html.erb +++ b/app/views/admin/users/_member_index.html.erb @@ -1,4 +1,5 @@ -
+<%= javascript_packs_with_chunks_tag "admin/users/memberIndex", defer: true %> +

Members

<%= render "admin/users/index/tabs" %> @@ -44,9 +45,9 @@ Good standing <% end %>
- +
+ <%= render "admin/users/index/user_actions_dropdown", user: user, id: "responsive-#{user.id}" %> +
@@ -97,7 +98,7 @@ Status Last activity & Joined on Organizations - Actions
+ Actions @@ -143,9 +144,7 @@ <% end %> - + <%= render "admin/users/index/user_actions_dropdown", user: user, id: "xl-#{user.id}" %> <% end %> diff --git a/app/views/admin/users/index/_user_actions_dropdown.html.erb b/app/views/admin/users/index/_user_actions_dropdown.html.erb new file mode 100644 index 000000000..24900f61e --- /dev/null +++ b/app/views/admin/users/index/_user_actions_dropdown.html.erb @@ -0,0 +1,9 @@ +
+ +
+ +
+
diff --git a/cypress/integration/seededFlows/adminFlows/users/userIndexView.spec.js b/cypress/integration/seededFlows/adminFlows/users/userIndexView.spec.js index 85dd3d248..a8382cf86 100644 --- a/cypress/integration/seededFlows/adminFlows/users/userIndexView.spec.js +++ b/cypress/integration/seededFlows/adminFlows/users/userIndexView.spec.js @@ -78,6 +78,34 @@ describe('User index view', () => { cy.url().should('contain', '/admin/users/1'); }); }); + + describe('User actions', () => { + it('Copies user email to clipboard', () => { + // Helper function for cypress-pipe + const click = (el) => el.click(); + + cy.findByRole('button', { name: 'User actions: Admin McAdmin' }) + .as('userActionsButton') + .pipe(click) + .should('have.attr', 'aria-expanded', 'true'); + + cy.findByRole('button', { name: 'Copy email address' }).click(); + + // Snackbar should appear with confirmation, and dropdown should close + cy.findByTestId('snackbar') + .findByText('Copied to clipboard') + .should('exist'); + cy.get('@userActionsButton') + .should('have.attr', 'aria-expanded', 'false') + .should('have.focus'); + + // Check the correct text is on the clipboard + cy.window() + .its('navigator.clipboard') + .invoke('readText') + .should('equal', 'admin@forem.local'); + }); + }); }); describe('large screens', () => { @@ -124,5 +152,33 @@ describe('User index view', () => { cy.url().should('contain', '/admin/users/1'); }); }); + + describe('User actions', () => { + it('Copies user email to clipboard', () => { + // Helper function for cypress-pipe + const click = (el) => el.click(); + + cy.findByRole('button', { name: 'User actions: Admin McAdmin' }) + .as('userActionsButton') + .pipe(click) + .should('have.attr', 'aria-expanded', 'true'); + + cy.findByRole('button', { name: 'Copy email address' }).click(); + + // Snackbar should appear with confirmation, and dropdown should close + cy.findByTestId('snackbar') + .findByText('Copied to clipboard') + .should('exist'); + cy.get('@userActionsButton') + .should('have.attr', 'aria-expanded', 'false') + .should('have.focus'); + + // Check the correct text is on the clipboard + cy.window() + .its('navigator.clipboard') + .invoke('readText') + .should('equal', 'admin@forem.local'); + }); + }); }); }); diff --git a/cypress/integration/seededFlows/policyFlows/limitPostCreationToAdmins.spec.js b/cypress/integration/seededFlows/policyFlows/limitPostCreationToAdmins.spec.js index 918f03278..99b71be32 100644 --- a/cypress/integration/seededFlows/policyFlows/limitPostCreationToAdmins.spec.js +++ b/cypress/integration/seededFlows/policyFlows/limitPostCreationToAdmins.spec.js @@ -13,7 +13,6 @@ describe('Limit Post Creation to Admins', () => { }); it('clicking on User Avatar should open User Dropdown menu and no Create Post is visible', () => { - cy.findByRole('button', { name: 'Navigation menu' }).as('menuButton'); cy.get('@menuButton') .should('have.attr', 'aria-expanded', 'false')