Member index: add copy email to clipboard functionality (#17146)
* functioning on large screen view * layout tweak * tidy up * remove redundant change * refocus dropdown button when clipboard copy executes and closes dropdown * add cypress test * add missed attr * implement in responsive layouts * use runtime copy utility
This commit is contained in:
parent
b6e2a735b7
commit
bd097bca25
6 changed files with 185 additions and 11 deletions
107
app/javascript/packs/admin/users/memberIndex.js
Normal file
107
app/javascript/packs/admin/users/memberIndex.js
Normal file
|
|
@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<div class="crayons-card overflow-admin-main-layout-padding p-4 xl:p-7">
|
||||
<%= javascript_packs_with_chunks_tag "admin/users/memberIndex", defer: true %>
|
||||
<div id="member-index-content" class="crayons-card overflow-admin-main-layout-padding p-4 xl:p-7">
|
||||
<header class="flex flex-col l:flex-row justify-content-between l:items-center ">
|
||||
<h1 class="crayons-title ml-2 m:ml-0">Members</h1>
|
||||
<%= render "admin/users/index/tabs" %>
|
||||
|
|
@ -44,9 +45,9 @@
|
|||
<span class="c-indicator mr-2 inline-block" style="--bg: #A3A3A3"></span>Good standing
|
||||
<% end %>
|
||||
</div>
|
||||
<button type="button" class="c-btn c-btn--icon-alone ml-auto" aria-label="User actions: #{user.name}">
|
||||
<%= crayons_icon_tag("overflow-vertical", aria_hidden: true) %>
|
||||
</button>
|
||||
<div class="ml-auto">
|
||||
<%= render "admin/users/index/user_actions_dropdown", user: user, id: "responsive-#{user.id}" %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex justify-between mb-2 block m:hidden">
|
||||
|
|
@ -97,7 +98,7 @@
|
|||
<th scope="col">Status</th>
|
||||
<th scope="col">Last activity & Joined on</th>
|
||||
<th scope="col">Organizations</th>
|
||||
<th scope="col" class="screen-reader-only">Actions</div>
|
||||
<th scope="col" class="screen-reader-only">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="crayons-card">
|
||||
|
|
@ -143,9 +144,7 @@
|
|||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="c-btn c-btn--icon-alone ml-auto" aria-label="User actions: #{user.name}">
|
||||
<%= crayons_icon_tag("overflow-vertical", aria_hidden: true) %>
|
||||
</button>
|
||||
<%= render "admin/users/index/user_actions_dropdown", user: user, id: "xl-#{user.id}" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<div class="relative">
|
||||
<button type="button" id="<%= id %>-action-dropdown-btn" data-toggle-dropdown="<%= id %>-action-dropdown" class="c-btn c-btn--icon-alone ml-auto"
|
||||
aria-expanded="false" aria-controls="<%= id %>-action-dropdown" aria-haspopup="true" aria-label="User actions: <%= user.name %>">
|
||||
<%= crayons_icon_tag("overflow-vertical", aria_hidden: true) %>
|
||||
</button>
|
||||
<div id="<%= id %>-action-dropdown" class="crayons-dropdown right-0">
|
||||
<button data-copy-email="<%= user.email %>" type="button" class="c-btn w-100 align-left"> <%= crayons_icon_tag("copy", aria_hidden: true) %>Copy email address</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue