Use a single listener for all follow buttons on a page (#14246)
* create new pack, handle user follow buttons, use pack on article page, remove initializeUserFollowButts * init all follow button types, add pack to tag index and podcast episode pages * add pack to all relevant pages, listen for newly inserted follow buttons * change to searchParams to remove follow button initializer calls * fix bug with tag page, add pack to notifications page * fix issue with follow back inner text * update cypress specs * run the followbuttons code on sponsors page * remove extra foreach * add test for follow from article sidebar * add test for follow and unfollow tag * add test for the tag index page * add spec for organisation profile follow * add tests for follow buttons in search results * add tests for notification follows * commit missed file - woops * show login modal if user is logged out when they click * add cypress tests for logged out state * change tag button initialization * remove data-button-initialized * init follow buttons from base pack * handle the case where multiple follow buttons exist on a page for the same user * account for instantclick and userdata not being defined, lower coverage for jest * use getInstantClick * fix issue with set initialisation * only listen for mutations in areas we know follow buttons may be added dynamically * small refactors
This commit is contained in:
parent
7c379c6d3b
commit
75b6c8ed96
34 changed files with 723 additions and 357 deletions
|
|
@ -24,7 +24,6 @@ module.exports = {
|
|||
browserStoreCache: false,
|
||||
initializeBaseUserData: false,
|
||||
initializeReadingListIcons: false,
|
||||
initializeAllFollowButts: false,
|
||||
initializeSponsorshipVisibility: false,
|
||||
ActiveXObject: false,
|
||||
AndroidBridge: false,
|
||||
|
|
|
|||
|
|
@ -54,8 +54,6 @@ function initializePage() {
|
|||
initializeAllTagEditButtons();
|
||||
}
|
||||
initializeBroadcast();
|
||||
initializeAllFollowButts();
|
||||
initializeUserFollowButts();
|
||||
initializeReadingListIcons();
|
||||
initializeSponsorshipVisibility();
|
||||
if (document.getElementById('sidebar-additional')) {
|
||||
|
|
|
|||
|
|
@ -1,261 +0,0 @@
|
|||
/* global showLoginModal */
|
||||
|
||||
function initializeAllFollowButts() {
|
||||
var followButts = document.getElementsByClassName('follow-action-button');
|
||||
for (var i = 0; i < followButts.length; i++) {
|
||||
if (!followButts[i].className.includes('follow-user')) {
|
||||
initializeFollowButt(followButts[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fetchUserFollowStatuses(idButtonHash) {
|
||||
const url = new URL('/follows/bulk_show', document.location);
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.keys(idButtonHash).forEach((id) => {
|
||||
searchParams.append('ids[]', id);
|
||||
});
|
||||
searchParams.append('followable_type', 'User');
|
||||
url.search = searchParams;
|
||||
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((idStatuses) => {
|
||||
Object.keys(idStatuses).forEach(function (id) {
|
||||
addButtClickHandles(idStatuses[id], idButtonHash[id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initializeUserFollowButtons(buttons) {
|
||||
if (buttons.length > 0) {
|
||||
var userIds = {};
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
var userStatus = document.body.getAttribute('data-user-status');
|
||||
if (userStatus === 'logged-out') {
|
||||
addModalEventListener(buttons[i]);
|
||||
} else {
|
||||
var userId = JSON.parse(buttons[i].dataset.info).id;
|
||||
if (userIds[userId]) {
|
||||
userIds[userId].push(buttons[i]);
|
||||
} else {
|
||||
userIds[userId] = [buttons[i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(userIds).length > 0) {
|
||||
fetchUserFollowStatuses(userIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initializeUserFollowButts() {
|
||||
// Get all user follow buttons, avoiding any initialized already
|
||||
var buttons = document.querySelectorAll(
|
||||
'.follow-action-button.follow-user:not([data-click-initialized])',
|
||||
);
|
||||
initializeUserFollowButtons(buttons);
|
||||
}
|
||||
|
||||
//private
|
||||
|
||||
function initializeFollowButt(butt) {
|
||||
var user = userData();
|
||||
var buttInfo = JSON.parse(butt.dataset.info);
|
||||
var userStatus = document
|
||||
.getElementsByTagName('body')[0]
|
||||
.getAttribute('data-user-status');
|
||||
if (userStatus === 'logged-out') {
|
||||
addModalEventListener(butt);
|
||||
return;
|
||||
}
|
||||
if (buttInfo.className === 'Tag' && user) {
|
||||
handleTagButtAssignment(user, butt, buttInfo);
|
||||
return;
|
||||
} else {
|
||||
if (butt.dataset.fetched === 'fetched') {
|
||||
return;
|
||||
}
|
||||
fetchButt(butt, buttInfo);
|
||||
}
|
||||
}
|
||||
|
||||
function addModalEventListener(butt) {
|
||||
assignState(butt, 'login');
|
||||
butt.onclick = function (e) {
|
||||
e.preventDefault();
|
||||
showLoginModal();
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
function fetchButt(butt, buttInfo) {
|
||||
butt.dataset.fetched = 'fetched';
|
||||
var dataRequester;
|
||||
if (window.XMLHttpRequest) {
|
||||
dataRequester = new XMLHttpRequest();
|
||||
} else {
|
||||
dataRequester = new ActiveXObject('Microsoft.XMLHTTP');
|
||||
}
|
||||
dataRequester.onreadystatechange = function () {
|
||||
if (
|
||||
dataRequester.readyState === XMLHttpRequest.DONE &&
|
||||
dataRequester.status === 200
|
||||
) {
|
||||
addButtClickHandles(dataRequester.response, [butt]);
|
||||
}
|
||||
};
|
||||
dataRequester.open(
|
||||
'GET',
|
||||
'/follows/' + buttInfo.id + '?followable_type=' + buttInfo.className,
|
||||
true,
|
||||
);
|
||||
dataRequester.send();
|
||||
}
|
||||
|
||||
function addButtClickHandles(response, buttons) {
|
||||
// currently lacking error handling
|
||||
buttons.forEach((butt) => {
|
||||
if (butt.dataset.clickInitialized !== 'true') {
|
||||
assignInitialButtResponse(response, butt);
|
||||
butt.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
handleOptimisticButtRender(butt);
|
||||
});
|
||||
butt.dataset.clickInitialized = 'true';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleTagButtAssignment(user, butt, buttInfo) {
|
||||
var buttAssignmentBoolean =
|
||||
JSON.parse(user.followed_tags)
|
||||
.map(function (a) {
|
||||
return a.id;
|
||||
})
|
||||
.indexOf(buttInfo.id) !== -1;
|
||||
|
||||
var buttAssignmentBoolText = buttAssignmentBoolean ? 'true' : 'false';
|
||||
addButtClickHandles(buttAssignmentBoolText, [butt]);
|
||||
}
|
||||
|
||||
function assignInitialButtResponse(response, butt) {
|
||||
butt.classList.add('showing');
|
||||
if (response === 'true' || response === 'mutual') {
|
||||
assignState(butt, 'unfollow');
|
||||
} else if (response === 'follow-back') {
|
||||
assignState(butt, 'follow-back');
|
||||
} else if (response === 'false') {
|
||||
assignState(butt, 'follow');
|
||||
} else if (response === 'self') {
|
||||
assignState(butt, 'self');
|
||||
} else {
|
||||
assignState(butt, 'login');
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptimisticButtRender(butt) {
|
||||
if (butt.dataset.verb === 'self') {
|
||||
window.location.href = '/settings';
|
||||
} else if (butt.dataset.verb === 'login') {
|
||||
showLoginModal();
|
||||
} else {
|
||||
// Handles actual following of tags/users
|
||||
try {
|
||||
//lets try grab the event buttons info data attribute user id
|
||||
var evFabUserId = JSON.parse(butt.dataset.info).id;
|
||||
var requestVerb = butt.dataset.verb;
|
||||
//now for all follow action buttons
|
||||
let actionButtons = document.getElementsByClassName(
|
||||
'follow-action-button',
|
||||
);
|
||||
Array.from(actionButtons).forEach(function (fab) {
|
||||
try {
|
||||
//lets check they have info data attributes
|
||||
if (fab.dataset.info) {
|
||||
//and attempt to parse those, to grab that buttons info user id
|
||||
var fabUserId = JSON.parse(fab.dataset.info).id;
|
||||
//now does that user id match our event buttons user id?
|
||||
if (fabUserId && fabUserId === evFabUserId) {
|
||||
//yes - time to assign the same state!
|
||||
assignState(fab, requestVerb);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleFollowButtPress(butt);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFollowButtPress(butt) {
|
||||
var buttonDataInfo = JSON.parse(butt.dataset.info);
|
||||
var formData = new FormData();
|
||||
formData.append('followable_type', buttonDataInfo.className);
|
||||
formData.append('followable_id', buttonDataInfo.id);
|
||||
formData.append('verb', butt.dataset.verb);
|
||||
getCsrfToken().then(sendFetch('follow-creation', formData));
|
||||
}
|
||||
|
||||
function assignState(butt, newState) {
|
||||
var style = JSON.parse(butt.dataset.info).style;
|
||||
var followStyle = JSON.parse(butt.dataset.info).followStyle;
|
||||
butt.classList.add('showing');
|
||||
if (newState === 'follow' || newState === 'follow-back') {
|
||||
butt.dataset.verb = 'unfollow';
|
||||
butt.classList.remove('crayons-btn--outlined');
|
||||
if (followStyle === 'primary') {
|
||||
butt.classList.add('crayons-btn--primary');
|
||||
} else if (followStyle === 'secondary') {
|
||||
butt.classList.add('crayons-btn--secondary');
|
||||
}
|
||||
if (newState === 'follow-back') {
|
||||
addFollowText(butt, newState);
|
||||
} else if (newState === 'follow') {
|
||||
addFollowText(butt, style);
|
||||
}
|
||||
} else if (newState === 'login') {
|
||||
addFollowText(butt, style);
|
||||
} else if (newState === 'self') {
|
||||
butt.dataset.verb = 'self';
|
||||
butt.textContent = 'Edit profile';
|
||||
} else {
|
||||
butt.dataset.verb = 'follow';
|
||||
addFollowingText(butt, style);
|
||||
butt.classList.remove('crayons-btn--primary');
|
||||
butt.classList.remove('crayons-btn--secondary');
|
||||
butt.classList.add('crayons-btn--outlined');
|
||||
}
|
||||
}
|
||||
|
||||
function addFollowText(butt, style) {
|
||||
if (style === 'small') {
|
||||
butt.textContent = '+';
|
||||
} else if (style === 'follow-back') {
|
||||
butt.textContent = 'Follow back';
|
||||
} else {
|
||||
butt.textContent = 'Follow';
|
||||
}
|
||||
}
|
||||
|
||||
function addFollowingText(butt, style) {
|
||||
if (style === 'small') {
|
||||
butt.textContent = '✓';
|
||||
} else {
|
||||
butt.textContent = 'Following';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
/* global initializeUserFollowButts */
|
||||
|
||||
function initializeLocalStorageRender() {
|
||||
try {
|
||||
var userData = browserStoreCache('get');
|
||||
|
|
@ -7,8 +5,6 @@ function initializeLocalStorageRender() {
|
|||
document.body.dataset.user = userData;
|
||||
initializeBaseUserData();
|
||||
initializeReadingListIcons();
|
||||
initializeAllFollowButts();
|
||||
initializeUserFollowButts();
|
||||
initializeSponsorshipVisibility();
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@
|
|||
* see: https://github.com/thepracticaldev/dev.to/issues/6468
|
||||
*/
|
||||
function sponsorClickHandler(event) {
|
||||
if (event.target.classList.contains('follow-action-button')) {
|
||||
handleOptimisticButtRender(event.target);
|
||||
handleFollowButtPress(event.target);
|
||||
}
|
||||
ga(
|
||||
'send',
|
||||
'event',
|
||||
|
|
|
|||
|
|
@ -39,11 +39,8 @@ window.Forem = {
|
|||
return;
|
||||
}
|
||||
|
||||
const [
|
||||
{ MentionAutocompleteTextArea },
|
||||
{ fetchSearch },
|
||||
{ render, h },
|
||||
] = await window.Forem.getMentionAutoCompleteImports();
|
||||
const [{ MentionAutocompleteTextArea }, { fetchSearch }, { render, h }] =
|
||||
await window.Forem.getMentionAutoCompleteImports();
|
||||
|
||||
render(
|
||||
<MentionAutocompleteTextArea
|
||||
|
|
@ -138,7 +135,9 @@ if (memberMenu) {
|
|||
}
|
||||
|
||||
getInstantClick().then((spa) => {
|
||||
spa.on('change', initializeNav);
|
||||
spa.on('change', () => {
|
||||
initializeNav();
|
||||
});
|
||||
});
|
||||
|
||||
initializeNav();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { addSnackbarItem } from '../Snackbar';
|
||||
import { initializeDropdown } from '@utilities/dropdownUtils';
|
||||
|
||||
/* global Runtime initializeUserFollowButts */
|
||||
/* global Runtime */
|
||||
|
||||
const handleCopyPermalink = (closeDropdown) => {
|
||||
return (event) => {
|
||||
|
|
@ -89,9 +89,6 @@ const fetchMissingProfilePreviewCard = async (placeholderElement) => {
|
|||
previewCard.id = dropdownContentId;
|
||||
|
||||
placeholderElement.parentNode.replaceChild(previewCard, placeholderElement);
|
||||
|
||||
// Make sure the button inside the dropdown is initialized
|
||||
initializeUserFollowButts();
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
366
app/javascript/packs/followButtons.js
Normal file
366
app/javascript/packs/followButtons.js
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { getInstantClick } from '../topNavigation/utilities';
|
||||
|
||||
/* global showLoginModal userData */
|
||||
|
||||
/**
|
||||
* Sets the text content of the button to the correct 'Follow' state
|
||||
*
|
||||
* @param {HTMLElement} button The Follow button to update
|
||||
* @param {string} style The style of the button from its "info" data attribute
|
||||
*/
|
||||
function addButtonFollowText(button, style) {
|
||||
switch (style) {
|
||||
case 'small':
|
||||
button.textContent = '+';
|
||||
break;
|
||||
case 'follow-back':
|
||||
button.textContent = 'Follow back';
|
||||
break;
|
||||
default:
|
||||
button.textContent = 'Follow';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the text content of the button to the correct 'Following' state
|
||||
*
|
||||
* @param {HTMLElement} button The Follow button to update
|
||||
* @param {string} style The style of the button from its "info" data attribute
|
||||
*/
|
||||
function addButtonFollowingText(button, style) {
|
||||
button.textContent = style === 'small' ? '✓' : 'Following';
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the visual appearance and 'verb' of the button to match the new state
|
||||
*
|
||||
* @param {HTMLElement} button The Follow button to be updated
|
||||
*/
|
||||
function optimisticallyUpdateButtonUI(button) {
|
||||
const { verb: newState } = button.dataset;
|
||||
const buttonInfo = JSON.parse(button.dataset.info);
|
||||
const { style } = buttonInfo;
|
||||
|
||||
// Often there are multiple follow buttons for the same followable item on the page
|
||||
// We collect all buttons which match the click, and update them all
|
||||
const matchingFollowButtons = Array.from(
|
||||
document.getElementsByClassName('follow-action-button'),
|
||||
).filter((button) => {
|
||||
const { info } = button.dataset;
|
||||
if (info) {
|
||||
const { id } = JSON.parse(info);
|
||||
return id === buttonInfo.id;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
matchingFollowButtons.forEach((matchingButton) => {
|
||||
matchingButton.classList.add('showing');
|
||||
|
||||
switch (newState) {
|
||||
case 'follow':
|
||||
case 'follow-back':
|
||||
updateFollowButton(matchingButton, newState, buttonInfo);
|
||||
break;
|
||||
case 'login':
|
||||
addButtonFollowText(matchingButton, style);
|
||||
break;
|
||||
case 'self':
|
||||
updateUserOwnFollowButton(matchingButton);
|
||||
break;
|
||||
default:
|
||||
updateFollowingButton(matchingButton, style);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Follow button's UI to the 'following' state
|
||||
*
|
||||
* @param {HTMLElement} button The Follow button to be updated
|
||||
* @param {string} style Style of the follow button (e.g. 'small')
|
||||
*/
|
||||
function updateFollowingButton(button, style) {
|
||||
button.dataset.verb = 'follow';
|
||||
addButtonFollowingText(button, style);
|
||||
button.classList.remove('crayons-btn--primary');
|
||||
button.classList.remove('crayons-btn--secondary');
|
||||
button.classList.add('crayons-btn--outlined');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the UI of the given button to the user's own button - i.e. 'Edit profile'
|
||||
*
|
||||
* @param {HTMLElement} button The Follow button to be updated
|
||||
*/
|
||||
function updateUserOwnFollowButton(button) {
|
||||
button.dataset.verb = 'self';
|
||||
button.textContent = 'Edit profile';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the UI of the given button to the 'follow' or 'follow-back' state
|
||||
*
|
||||
* @param {HTMLElement} button The Follow button to be updated
|
||||
* @param {string} newState The new follow state of the button
|
||||
* @param {Object} buttonInfo The parsed info object obtained from the button's dataset
|
||||
* @param {string} buttonInfo.style The style of the follow button (e.g 'small')
|
||||
* @param {string} buttonInfo.followStyle The crayons button variant (e.g 'primary')
|
||||
*/
|
||||
function updateFollowButton(button, newState, buttonInfo) {
|
||||
const { style, followStyle } = buttonInfo;
|
||||
|
||||
button.dataset.verb = 'unfollow';
|
||||
button.classList.remove('crayons-btn--outlined');
|
||||
|
||||
if (followStyle === 'primary') {
|
||||
button.classList.add('crayons-btn--primary');
|
||||
} else if (followStyle === 'secondary') {
|
||||
button.classList.add('crayons-btn--secondary');
|
||||
}
|
||||
|
||||
const nextButtonStyle = newState === 'follow-back' ? newState : style;
|
||||
addButtonFollowText(button, nextButtonStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a click event's target, and if it is a follow button, triggers the appropriate follow action
|
||||
*
|
||||
* @param {HTMLElement} target The target of the click event
|
||||
*/
|
||||
function handleFollowButtonClick({ target }) {
|
||||
if (
|
||||
target.classList.contains('follow-action-button') ||
|
||||
target.classList.contains('follow-user')
|
||||
) {
|
||||
const userStatus = document.body.getAttribute('data-user-status');
|
||||
if (userStatus === 'logged-out') {
|
||||
showLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
optimisticallyUpdateButtonUI(target);
|
||||
|
||||
const { verb } = target.dataset;
|
||||
|
||||
if (verb === 'self') {
|
||||
window.location.href = '/settings';
|
||||
return;
|
||||
}
|
||||
|
||||
const { className, id } = JSON.parse(target.dataset.info);
|
||||
const formData = new FormData();
|
||||
formData.append('followable_type', className);
|
||||
formData.append('followable_id', id);
|
||||
formData.append('verb', verb);
|
||||
getCsrfToken().then(sendFetch('follow-creation', formData));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener to the inner page content, to handle any and all follow button clicks with a single handler
|
||||
*/
|
||||
function listenForFollowButtonClicks() {
|
||||
document
|
||||
.getElementById('page-content-inner')
|
||||
.addEventListener('click', handleFollowButtonClick);
|
||||
|
||||
document.getElementById(
|
||||
'page-content-inner',
|
||||
).dataset.followClicksInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the UI of the button based on the current following status
|
||||
*
|
||||
* @param {string} followStatus The current following status for the button
|
||||
* @param {HTMLElement} button The button to update
|
||||
*/
|
||||
function updateInitialButtonUI(followStatus, button) {
|
||||
const buttonInfo = JSON.parse(button.dataset.info);
|
||||
const { style } = buttonInfo;
|
||||
button.classList.add('showing');
|
||||
|
||||
switch (followStatus) {
|
||||
case 'true':
|
||||
case 'mutual':
|
||||
updateFollowingButton(button, style);
|
||||
break;
|
||||
case 'follow-back':
|
||||
addButtonFollowText(button, followStatus);
|
||||
break;
|
||||
case 'false':
|
||||
updateFollowButton(button, 'follow', buttonInfo);
|
||||
break;
|
||||
case 'self':
|
||||
updateUserOwnFollowButton(button);
|
||||
break;
|
||||
default:
|
||||
addButtonFollowText(button, style);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all user 'follow statuses' for the given userIds, and then updates the UI for all buttons related to each user
|
||||
*
|
||||
* @param {Object} idButtonHash A hash of user IDs and the array buttons which relate to them
|
||||
*/
|
||||
function fetchUserFollowStatuses(idButtonHash) {
|
||||
const url = new URL('/follows/bulk_show', document.location);
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.keys(idButtonHash).forEach((id) => {
|
||||
searchParams.append('ids[]', id);
|
||||
});
|
||||
searchParams.append('followable_type', 'User');
|
||||
url.search = searchParams;
|
||||
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((idStatuses) => {
|
||||
Object.keys(idStatuses).forEach((id) => {
|
||||
idButtonHash[id].forEach((button) => {
|
||||
updateInitialButtonUI(idStatuses[id], button);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the initial state of all user follow buttons on the page,
|
||||
* by obtaining the 'follow status' of each user and updating the associated buttons' UI.
|
||||
*/
|
||||
function initializeAllUserFollowButtons() {
|
||||
const buttons = document.querySelectorAll(
|
||||
'.follow-action-button.follow-user:not([data-fetched])',
|
||||
);
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userIds = {};
|
||||
|
||||
Array.from(buttons, (button) => {
|
||||
button.dataset.fetched = 'fetched';
|
||||
const { userStatus } = document.body.dataset;
|
||||
|
||||
if (userStatus === 'logged-out') {
|
||||
const { style } = JSON.parse(button.dataset.info);
|
||||
addButtonFollowText(button, style);
|
||||
} else {
|
||||
const { id: userId } = JSON.parse(button.dataset.info);
|
||||
if (userIds[userId]) {
|
||||
userIds[userId].push(button);
|
||||
} else {
|
||||
userIds[userId] = [button];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(userIds).length > 0) {
|
||||
fetchUserFollowStatuses(userIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individually fetches the current status of a follow button and updates the UI to match
|
||||
*
|
||||
* @param {HTMLElement} button
|
||||
* @param {Object} buttonInfo The parsed buttonInfo object obtained from the button's data-attribute
|
||||
*/
|
||||
function fetchFollowButtonStatus(button, buttonInfo) {
|
||||
button.dataset.fetched = 'fetched';
|
||||
|
||||
fetch(`/follows/${buttonInfo.id}?followable_type=${buttonInfo.className}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then((response) => response.text())
|
||||
.then((followStatus) => {
|
||||
updateInitialButtonUI(followStatus, button);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the initial state of follow buttons is fetched and presented in the UI.
|
||||
* User follow buttons are initialized separately via bulk request
|
||||
*/
|
||||
function initializeNonUserFollowButtons() {
|
||||
const nonUserFollowButtons = document.querySelectorAll(
|
||||
'.follow-action-button:not(.follow-user):not([data-fetched])',
|
||||
);
|
||||
|
||||
const userLoggedIn =
|
||||
document.body.getAttribute('data-user-status') === 'logged-in';
|
||||
|
||||
const user = userLoggedIn ? userData() : null;
|
||||
|
||||
const followedTags = user
|
||||
? JSON.parse(user.followed_tags).map((tag) => tag.id)
|
||||
: [];
|
||||
|
||||
const followedTagIds = new Set(followedTags);
|
||||
|
||||
nonUserFollowButtons.forEach((button) => {
|
||||
const { info } = button.dataset;
|
||||
const buttonInfo = JSON.parse(info);
|
||||
if (buttonInfo.className === 'Tag' && user) {
|
||||
// We don't need to make a network request to 'fetch' the status of tag buttons
|
||||
button.dataset.fetched = true;
|
||||
const initialButtonFollowState = followedTagIds.has(buttonInfo.id)
|
||||
? 'true'
|
||||
: 'false';
|
||||
updateInitialButtonUI(initialButtonFollowState, button);
|
||||
} else {
|
||||
fetchFollowButtonStatus(button, buttonInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initializeAllUserFollowButtons();
|
||||
initializeNonUserFollowButtons();
|
||||
listenForFollowButtonClicks();
|
||||
|
||||
// Some follow buttons are added to the DOM dynamically, e.g. search results,
|
||||
// So we listen for any new additions to be fetched
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
mutationsList.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
initializeAllUserFollowButtons();
|
||||
initializeNonUserFollowButtons();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Any element containing the given data-attribute will be monitored for new follow buttons
|
||||
document
|
||||
.querySelectorAll('[data-follow-button-container]')
|
||||
.forEach((followButtonContainer) => {
|
||||
observer.observe(followButtonContainer, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
|
||||
getInstantClick().then((ic) => {
|
||||
ic.on('change', () => {
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
observer.disconnect();
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
/* global checkUserLoggedIn, showLoginModal, userData, buildArticleHTML, initializeReadingListIcons, initializeAllFollowButts, initializeUserFollowButts */
|
||||
/* global checkUserLoggedIn, showLoginModal, userData, buildArticleHTML, initializeReadingListIcons */
|
||||
/* eslint no-undef: "error" */
|
||||
|
||||
function getQueryParams(qs) {
|
||||
|
|
@ -190,8 +190,6 @@ function search(query, filters, sortBy, sortDirection) {
|
|||
});
|
||||
document.getElementById('substories').innerHTML = resultDivs.join('');
|
||||
initializeReadingListIcons();
|
||||
initializeAllFollowButts();
|
||||
initializeUserFollowButts();
|
||||
document
|
||||
.getElementById('substories')
|
||||
.classList.add('search-results-loaded');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<% cache("whole-comment-area-#{@article.id}-#{@article.last_comment_at}-#{@article.show_comments}-#{@discussion_lock&.updated_at}", expires_in: 2.hours) do %>
|
||||
<section id="comments" data-updated-at="<%= Time.current %>" class="text-padding mb-4 border-t-1 border-0 border-solid border-base-10">
|
||||
<section id="comments" data-follow-button-container="true" data-updated-at="<%= Time.current %>" class="text-padding mb-4 border-t-1 border-0 border-solid border-base-10">
|
||||
<% if @article.show_comments %>
|
||||
<header class="relative flex justify-between items-center mb-6">
|
||||
<h2 class="crayons-subtitle-1">Discussion <span class="js-comments-count" data-comments-count="<%= @article.comments_count %>">(<%= @article.comments_count %>)</span></h2>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
<%= render "articles/search/nav_menu" %>
|
||||
</div>
|
||||
|
||||
<div class="articles-list crayons-layout__content" id="articles-list">
|
||||
<div class="articles-list crayons-layout__content" id="articles-list" data-follow-button-container="true">
|
||||
<div id="banner-section"></div>
|
||||
<div class="substories" id="substories">
|
||||
<div class="p-9 align-center crayons-card">
|
||||
|
|
@ -55,4 +55,4 @@
|
|||
</div>
|
||||
</main>
|
||||
|
||||
<%= javascript_packs_with_chunks_tag "searchParams", "storiesList", defer: true %>
|
||||
<%= javascript_packs_with_chunks_tag "searchParams", "storiesList", "followButtons", defer: true %>
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@
|
|||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="crayons-layout__sidebar-right" aria-label="Right sidebar navigation">
|
||||
<aside class="crayons-layout__sidebar-right" aria-label="Author details">
|
||||
<% stick_nav_cache_key = "sticky-sidebar-#{@article.id}-#{(@organization || @user).profile_updated_at}-#{(@organization || @user).latest_article_updated_at}" %>
|
||||
<% cache(stick_nav_cache_key, expires_in: 50.hours) do %>
|
||||
<%= render "articles/sticky_nav" %>
|
||||
|
|
@ -229,6 +229,7 @@
|
|||
<div data-testid="flag-user-modal-container" class="flag-user-modal-container hidden"></div>
|
||||
|
||||
<div class="fullscreen-code js-fullscreen-code"></div>
|
||||
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>
|
||||
|
||||
<% cache("article-show-scripts", expires_in: 8.hours) do %>
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
<div class="crayons-layout__sidebar-left">
|
||||
<%= render "notifications/nav_menu" %>
|
||||
</div>
|
||||
<main class="crayons-layout__content">
|
||||
<main class="crayons-layout__content" data-follow-button-container="true">
|
||||
<div id="articles-list">
|
||||
<%= render "notifications_list", params: params %>
|
||||
</div>
|
||||
|
|
@ -68,6 +68,7 @@
|
|||
</div>
|
||||
<%= render "articles/fitvids" %>
|
||||
</main>
|
||||
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>
|
||||
<% else %>
|
||||
<%= render "devise/registrations/registration_form" %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -24,4 +24,4 @@
|
|||
<%= render "organizations/sidebar_additional" %>
|
||||
</div>
|
||||
|
||||
<%= javascript_packs_with_chunks_tag "storiesList", defer: true %>
|
||||
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", defer: true %>
|
||||
|
|
|
|||
|
|
@ -67,3 +67,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@
|
|||
class="comments-container text-padding"
|
||||
id="comments-container"
|
||||
data-commentable-id="<%= @episode.id %>"
|
||||
data-commentable-type="PodcastEpisode">
|
||||
data-commentable-type="PodcastEpisode"
|
||||
data-follow-button-container="true">
|
||||
<%= javascript_packs_with_chunks_tag "commentDropdowns", defer: true %>
|
||||
<%= render "/comments/form",
|
||||
commentable: @episode,
|
||||
|
|
@ -110,3 +111,4 @@
|
|||
</main>
|
||||
|
||||
<%= render "podcast_episodes/podcast_bar", podcast: @podcast, episode: @episode %>
|
||||
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>
|
||||
|
|
|
|||
|
|
@ -113,5 +113,5 @@
|
|||
<%= render "stories/tagged_articles/sidebar_additional" %>
|
||||
</div>
|
||||
|
||||
<%= javascript_packs_with_chunks_tag "storiesList", defer: true %>
|
||||
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", defer: true %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<h1>Top tags</h1>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-2 m:gap-4 l:gap-6 m:grid-cols-2 l:grid-cols-3 px-2 m:px-0">
|
||||
<div class="grid gap-2 m:gap-4 l:gap-6 m:grid-cols-2 l:grid-cols-3 px-2 m:px-0" data-follow-button-container="true">
|
||||
<% @tags.each do |tag| %>
|
||||
<% color = Color::CompareHex.new([tag.bg_color_hex || "#0000000", tag.text_color_hex || "#ffffff"]).brightness(0.8) %>
|
||||
<div class="tag-card crayons-card branded-4 p-4 m:p-6 m:pt-4 flex flex-col relative" style="border-top-color: <%= color %>; ">
|
||||
|
|
@ -60,3 +60,4 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</main>
|
||||
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
<button class="chat-action-button crayons-btn crayons-btn--outlined hidden" id="modal-opener">Chat</button>
|
||||
</a>
|
||||
<% end %>
|
||||
<button id="user-follow-butt" class="crayons-btn whitespace-nowrap follow-action-button user-profile-follow-button" data-info='{"id":<%= @user.id %>,"className":"<%= @user.class.name %>"}'>Follow</button>
|
||||
<button id="user-follow-butt" class="crayons-btn whitespace-nowrap follow-action-button user-profile-follow-button follow-user" data-info='{"id":<%= @user.id %>,"className":"<%= @user.class.name %>"}'>Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -158,4 +158,4 @@
|
|||
</main>
|
||||
</div>
|
||||
|
||||
<%= javascript_packs_with_chunks_tag "storiesList", defer: true %>
|
||||
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", defer: true %>
|
||||
|
|
|
|||
5
cypress/fixtures/users/notificationsUser.json
Normal file
5
cypress/fixtures/users/notificationsUser.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"username": "notifications_user",
|
||||
"email": "notifications-user@forem.local",
|
||||
"password": "password"
|
||||
}
|
||||
34
cypress/integration/articleFlows/followAuthor.spec.js
Normal file
34
cypress/integration/articleFlows/followAuthor.spec.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
describe('Follow author from article sidebar', () => {
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.viewport('macbook-16');
|
||||
cy.fixture('users/articleEditorV1User.json').as('user');
|
||||
|
||||
cy.get('@user').then((user) => {
|
||||
cy.loginAndVisit(user, '/').then(() => {
|
||||
cy.findAllByRole('link', { name: 'Test article' })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
cy.findByRole('heading', { name: 'Test article' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Follows and unfollows an author from the sidebar', () => {
|
||||
cy.intercept('/follows').as('followRequest');
|
||||
|
||||
cy.findByRole('complementary', { name: 'Author details' }).within(() => {
|
||||
cy.findByRole('button', { name: 'Follow' }).as('followButton');
|
||||
cy.get('@followButton').click();
|
||||
|
||||
cy.wait('@followRequest');
|
||||
cy.get('@followButton').should('have.text', 'Following');
|
||||
|
||||
cy.get('@followButton').click();
|
||||
cy.wait('@followRequest');
|
||||
cy.get('@followButton').should('have.text', 'Follow');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
describe('Follow user from notifications', () => {
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.fixture('users/notificationsUser.json').as('user');
|
||||
|
||||
cy.get('@user').then((user) => {
|
||||
cy.loginAndVisit(user, '/notifications');
|
||||
});
|
||||
});
|
||||
|
||||
it('Follows and unfollows a user from a follow notification', () => {
|
||||
cy.findByRole('heading', { name: 'Notifications' });
|
||||
cy.intercept('/follows').as('followsRequest');
|
||||
|
||||
cy.findByRole('button', { name: 'Follow back' }).as('followButton');
|
||||
cy.get('@followButton').click();
|
||||
cy.wait('@followsRequest');
|
||||
|
||||
cy.get('@followButton').should('have.text', 'Following');
|
||||
|
||||
cy.get('@followButton').click();
|
||||
cy.wait('@followsRequest');
|
||||
cy.get('@followButton').should('have.text', 'Follow back');
|
||||
});
|
||||
});
|
||||
31
cypress/integration/profileFlows/followOrganisation.spec.js
Normal file
31
cypress/integration/profileFlows/followOrganisation.spec.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
describe('Follow user from profile page', () => {
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.fixture('users/adminUser.json').as('user');
|
||||
|
||||
cy.get('@user').then((user) => {
|
||||
cy.loginAndVisit(user, '/bachmanity');
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
});
|
||||
});
|
||||
|
||||
it('follows and unfollows an organisation', () => {
|
||||
cy.intercept('/follows').as('followsRequest');
|
||||
|
||||
cy.findByRole('button', { name: 'Follow' }).click();
|
||||
cy.wait('@followsRequest');
|
||||
cy.findByRole('button', { name: 'Following' });
|
||||
|
||||
// Check that the update persists after reload
|
||||
cy.visitAndWaitForUserSideEffects('/bachmanity');
|
||||
|
||||
cy.findByRole('button', { name: 'Following' }).click();
|
||||
cy.wait('@followsRequest');
|
||||
|
||||
cy.findByRole('button', { name: 'Follow' });
|
||||
|
||||
// Check that the update persists after reload
|
||||
cy.visitAndWaitForUserSideEffects('/bachmanity');
|
||||
cy.findByRole('button', { name: 'Follow' });
|
||||
});
|
||||
});
|
||||
37
cypress/integration/searchFlows/followUser.spec.js
Normal file
37
cypress/integration/searchFlows/followUser.spec.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
describe('Follow user from search results', () => {
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.fixture('users/adminUser.json').as('user');
|
||||
|
||||
cy.get('@user').then((user) => {
|
||||
cy.loginUser(user);
|
||||
});
|
||||
});
|
||||
|
||||
it('Shows an edit profile button for the current user', () => {
|
||||
cy.visitAndWaitForUserSideEffects(
|
||||
'/search?q=admin&filters=class_name:User',
|
||||
);
|
||||
|
||||
cy.findByRole('button', { name: 'Edit profile' }).click();
|
||||
cy.findByRole('heading', { name: 'Settings for @admin_mcadmin' });
|
||||
});
|
||||
|
||||
it('Follows and unfollows a user from search results', () => {
|
||||
cy.visitAndWaitForUserSideEffects(
|
||||
'/search?q=article&filters=class_name:User',
|
||||
);
|
||||
|
||||
cy.intercept('/follows').as('followsRequest');
|
||||
|
||||
cy.findAllByRole('button', { name: 'Follow' }).first().as('followButton');
|
||||
cy.get('@followButton').click();
|
||||
|
||||
cy.wait('@followsRequest');
|
||||
cy.get('@followButton').should('have.text', 'Following');
|
||||
|
||||
cy.get('@followButton').click();
|
||||
cy.wait('@followsRequest');
|
||||
cy.get('@followButton').should('have.text', 'Follow');
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,8 @@ describe('Preview user profile from article page', () => {
|
|||
cy.findAllByRole('link', { name: 'Test article' })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
cy.findByRole('heading', { name: 'Test article' });
|
||||
});
|
||||
});
|
||||
|
|
@ -79,13 +81,10 @@ describe('Preview user profile from article page', () => {
|
|||
cy.findByText('Edinburgh');
|
||||
cy.findByText('University of Life');
|
||||
|
||||
// Make sure click event is initialized, and check we can follow a user
|
||||
cy.get('[data-click-initialized]').should('exist');
|
||||
cy.findByRole('button', { name: 'Follow' }).click();
|
||||
|
||||
// Wait for Follow button to disappear and Following button to be initialized
|
||||
cy.findByRole('button', { name: 'Follow' }).should('not.exist');
|
||||
cy.get('[data-click-initialized]').should('exist');
|
||||
cy.findByRole('button', { name: 'Following' });
|
||||
});
|
||||
|
||||
|
|
@ -121,18 +120,41 @@ describe('Preview user profile from article page', () => {
|
|||
cy.findByText('Edinburgh');
|
||||
cy.findByText('University of Life');
|
||||
|
||||
// Make sure click event is initialized, and check we can follow a user
|
||||
cy.get('[data-click-initialized]').should('exist');
|
||||
cy.findByRole('button', { name: 'Follow' }).click();
|
||||
|
||||
// Wait for Follow button to disappear and Following button to be initialized
|
||||
cy.findByRole('button', { name: 'Follow' }).should('not.exist');
|
||||
cy.get('[data-click-initialized]').should('exist');
|
||||
cy.findByRole('button', { name: 'Following' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should update any other matching follow buttons when follow CTA is clicked', () => {
|
||||
cy.get('[data-initialized]');
|
||||
// Click the follow button in the author byline preview
|
||||
cy.findAllByRole('button', { name: 'Admin McAdmin profile details' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
cy.findAllByTestId('profile-preview-card')
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByRole('button', { name: 'Follow' }).click();
|
||||
// Confirm the follow button has been updated
|
||||
cy.findByRole('button', { name: 'Follow' }).should('not.exist');
|
||||
cy.findByRole('button', { name: 'Following' });
|
||||
});
|
||||
|
||||
// Check the follow button in the comment author preview card has updated
|
||||
cy.findAllByRole('button', { name: 'Admin McAdmin profile details' })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
cy.findAllByTestId('profile-preview-card')
|
||||
.last()
|
||||
.findByRole('button', { name: 'Following' });
|
||||
});
|
||||
|
||||
it('should detach listeners on preview card close', () => {
|
||||
cy.findAllByRole('button', { name: 'Admin McAdmin profile details' })
|
||||
.first()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,17 @@
|
|||
describe('Show log in modal', () => {
|
||||
const verifyLoginModalBehavior = (getTriggerElement) => {
|
||||
getTriggerElement().click();
|
||||
cy.findByTestId('modal-container').as('modal');
|
||||
cy.get('@modal').findByText('Log in to continue').should('exist');
|
||||
cy.get('@modal').findByLabelText('Log in').should('exist');
|
||||
cy.get('@modal').findByLabelText('Create new account').should('exist');
|
||||
cy.get('@modal').findByRole('button').first().should('have.focus');
|
||||
|
||||
cy.get('@modal').findByRole('button', { name: /Close/ }).click();
|
||||
getTriggerElement().should('have.focus');
|
||||
cy.findByTestId('modal-container').should('not.exist');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.visit('/');
|
||||
|
|
@ -9,17 +22,7 @@ describe('Show log in modal', () => {
|
|||
// Wait for the click handler to be attached to the button
|
||||
cy.get('@bookmarkButton').should('have.attr', 'data-save-initialized');
|
||||
|
||||
cy.get('@bookmarkButton').click();
|
||||
|
||||
cy.findByTestId('modal-container').as('modal');
|
||||
cy.get('@modal').findByText('Log in to continue').should('exist');
|
||||
cy.get('@modal').findByLabelText('Log in').should('exist');
|
||||
cy.get('@modal').findByLabelText('Create new account').should('exist');
|
||||
cy.get('@modal').findByRole('button').first().should('have.focus');
|
||||
|
||||
cy.get('@modal').findByRole('button', { name: /Close/ }).click();
|
||||
cy.get('@bookmarkButton').should('have.focus');
|
||||
cy.findByTestId('modal-container').should('not.exist');
|
||||
verifyLoginModalBehavior(() => cy.get('@bookmarkButton'));
|
||||
});
|
||||
|
||||
it('should show login modal for article reaction clicks', () => {
|
||||
|
|
@ -35,35 +38,52 @@ describe('Show log in modal', () => {
|
|||
|
||||
['@heartReaction', '@unicornReaction', '@bookmarkReaction'].forEach(
|
||||
(reaction) => {
|
||||
cy.get(reaction).click();
|
||||
|
||||
cy.findByTestId('modal-container').as('modal');
|
||||
cy.get('@modal').findByText('Log in to continue').should('exist');
|
||||
cy.get('@modal').findByLabelText('Log in').should('exist');
|
||||
cy.get('@modal').findByLabelText('Create new account').should('exist');
|
||||
cy.get('@modal').findByRole('button').first().should('have.focus');
|
||||
|
||||
cy.get('@modal').findByRole('button', { name: /Close/ }).click();
|
||||
cy.get(reaction).should('have.focus');
|
||||
cy.findByTestId('modal-container').should('not.exist');
|
||||
verifyLoginModalBehavior(() => cy.get(reaction));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should show login modal for comment subscription', () => {
|
||||
cy.findAllByText('Test article').last().click();
|
||||
cy.findByRole('button', { name: /Subscribe/ })
|
||||
.as('subscribe')
|
||||
.click();
|
||||
|
||||
cy.findByTestId('modal-container').as('modal');
|
||||
cy.get('@modal').findByText('Log in to continue').should('exist');
|
||||
cy.get('@modal').findByLabelText('Log in').should('exist');
|
||||
cy.get('@modal').findByLabelText('Create new account').should('exist');
|
||||
cy.get('@modal').findByRole('button').first().should('have.focus');
|
||||
verifyLoginModalBehavior(() =>
|
||||
cy.findByRole('button', { name: /Subscribe/ }),
|
||||
);
|
||||
});
|
||||
|
||||
cy.get('@modal').findByRole('button', { name: /Close/ }).click();
|
||||
cy.get('@subscribe').should('have.focus');
|
||||
cy.findByTestId('modal-container').should('not.exist');
|
||||
it('should show login modal for article follow button click', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.findAllByRole('link', { name: 'Test article' })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
|
||||
verifyLoginModalBehavior(() =>
|
||||
cy
|
||||
.findByRole('complementary', { name: 'Author details' })
|
||||
.findByRole('button', { name: 'Follow' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show login modal for tag follow button click', () => {
|
||||
cy.visit('/tags');
|
||||
cy.findByRole('heading', { name: 'Top tags' });
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
|
||||
verifyLoginModalBehavior(() => cy.findByRole('button', { name: 'Follow' }));
|
||||
|
||||
cy.visit('/t/tag1');
|
||||
cy.findByRole('heading', { name: '# tag1' });
|
||||
|
||||
verifyLoginModalBehavior(() => cy.findByRole('button', { name: 'Follow' }));
|
||||
});
|
||||
|
||||
it('should show login modal for user profile follow button click', () => {
|
||||
cy.visit('/admin_mcadmin');
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
|
||||
cy.findByRole('heading', { name: 'Admin McAdmin' });
|
||||
verifyLoginModalBehavior(() => cy.findByRole('button', { name: 'Follow' }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,10 @@ describe('User Change Password', () => {
|
|||
|
||||
// We intercept these requests to make sure all async sign-in requests have completed before finishing the test.
|
||||
// This ensures async responses do not intefere with subsequent test setup
|
||||
const loginNetworkRequests = getInterceptsForLingeringUserRequests(true);
|
||||
const loginNetworkRequests = getInterceptsForLingeringUserRequests(
|
||||
'/',
|
||||
true,
|
||||
);
|
||||
|
||||
// Submit the form
|
||||
cy.get('@loginForm').findByText('Continue').click();
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ describe('User Logout', () => {
|
|||
cy.findByText('Sign Out').click({ force: true });
|
||||
|
||||
// We intercept user-related network requests triggered on logout, so we can await them and avoid issues with a subsequent login
|
||||
const logoutNetworkRequests = getInterceptsForLingeringUserRequests(false);
|
||||
const logoutNetworkRequests = getInterceptsForLingeringUserRequests(
|
||||
'/signout_confirm',
|
||||
false,
|
||||
);
|
||||
|
||||
// Sign out confirmation page is rendered
|
||||
cy.url().should('contains', '/signout_confirm');
|
||||
|
|
|
|||
|
|
@ -5,25 +5,27 @@ describe('Follow user from profile page', () => {
|
|||
|
||||
cy.get('@user').then((user) => {
|
||||
cy.loginAndVisit(user, '/article_editor_v1_user');
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
});
|
||||
});
|
||||
|
||||
it('follows and unfollows a user', () => {
|
||||
// Wait for the button to be initialised
|
||||
cy.get('[data-click-initialized="true"]');
|
||||
cy.intercept('/follows').as('followsRequest');
|
||||
|
||||
cy.findByRole('button', { name: 'Follow' }).click();
|
||||
cy.wait('@followsRequest');
|
||||
cy.findByRole('button', { name: 'Following' });
|
||||
|
||||
// Check that the update persists after reload
|
||||
cy.reload();
|
||||
cy.get('[data-click-initialized="true"]');
|
||||
cy.visitAndWaitForUserSideEffects('/article_editor_v1_user');
|
||||
|
||||
cy.findByRole('button', { name: 'Following' }).click();
|
||||
cy.wait('@followsRequest');
|
||||
|
||||
cy.findByRole('button', { name: 'Follow' });
|
||||
|
||||
// Check that the update persists after reload
|
||||
cy.reload();
|
||||
cy.get('[data-click-initialized="true"]');
|
||||
cy.visitAndWaitForUserSideEffects('/article_editor_v1_user');
|
||||
cy.findByRole('button', { name: 'Follow' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
59
cypress/integration/tagsFlows/followTag.spec.js
Normal file
59
cypress/integration/tagsFlows/followTag.spec.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
describe('Follow tag', () => {
|
||||
describe('Tag index page', () => {
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.fixture('users/articleEditorV1User.json').as('user');
|
||||
|
||||
cy.get('@user').then((user) => {
|
||||
cy.loginAndVisit(user, '/tags').then(() => {
|
||||
cy.findByRole('heading', { name: 'Top tags' });
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Follows and unfollows a tag from the tag index page', () => {
|
||||
cy.intercept('/follows').as('followsRequest');
|
||||
cy.findByRole('button', { name: 'Follow' }).as('followButton');
|
||||
|
||||
cy.get('@followButton').click();
|
||||
cy.wait('@followsRequest');
|
||||
|
||||
cy.get('@followButton').should('have.text', 'Following');
|
||||
|
||||
cy.get('@followButton').click();
|
||||
cy.wait('@followsRequest');
|
||||
|
||||
cy.get('@followButton').should('have.text', 'Follow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag feed page', () => {
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.fixture('users/articleEditorV1User.json').as('user');
|
||||
|
||||
cy.get('@user').then((user) => {
|
||||
cy.loginAndVisit(user, '/t/tag1').then(() => {
|
||||
cy.findByRole('heading', { name: '# tag1' });
|
||||
cy.get('[data-follow-clicks-initialized]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Follows and unfollows a tag from the tag feed page', () => {
|
||||
cy.intercept('/follows').as('followsRequest');
|
||||
cy.findByRole('button', { name: 'Follow' }).as('followButton');
|
||||
|
||||
cy.get('@followButton').click();
|
||||
cy.wait('@followsRequest');
|
||||
|
||||
cy.get('@followButton').should('have.text', 'Following');
|
||||
|
||||
cy.get('@followButton').click();
|
||||
cy.wait('@followsRequest');
|
||||
|
||||
cy.get('@followButton').should('have.text', 'Follow');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -30,7 +30,7 @@ import { getInterceptsForLingeringUserRequests } from '../util/networkUtils';
|
|||
* Use this function to sign a user out without lingering network calls causing unintended side-effects.
|
||||
*/
|
||||
Cypress.Commands.add('signOutUser', () => {
|
||||
const intercepts = getInterceptsForLingeringUserRequests(false);
|
||||
const intercepts = getInterceptsForLingeringUserRequests('/', false);
|
||||
|
||||
return cy.request('DELETE', '/users/sign_out').then(() => {
|
||||
cy.visit('/');
|
||||
|
|
@ -58,7 +58,7 @@ Cypress.Commands.add('visitAndWaitForUserSideEffects', (url, options) => {
|
|||
if (url === `${baseUrl}/admin` || url.includes('/admin/')) {
|
||||
cy.visit(url, options);
|
||||
} else {
|
||||
const intercepts = getInterceptsForLingeringUserRequests(true);
|
||||
const intercepts = getInterceptsForLingeringUserRequests(url, true);
|
||||
cy.visit(url, options);
|
||||
cy.wait(intercepts);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
* @param {Boolean} userLoggedIn Whether or not the user state is transitioning to logged in - defaults to false
|
||||
* @returns {Array} Array of aliased Cypress intercepts which may be awaited to ensure they run to completion
|
||||
*/
|
||||
export const getInterceptsForLingeringUserRequests = (userLoggedIn = false) => {
|
||||
export const getInterceptsForLingeringUserRequests = (
|
||||
url,
|
||||
userLoggedIn = false,
|
||||
) => {
|
||||
// Stub these as response not needed to test app functionality
|
||||
cy.intercept('/api/analytics/historical**', {});
|
||||
cy.intercept('/api/analytics/referrers**', {});
|
||||
|
|
@ -18,13 +21,16 @@ export const getInterceptsForLingeringUserRequests = (userLoggedIn = false) => {
|
|||
}
|
||||
|
||||
cy.intercept('/chat_channels**').as('chatRequest');
|
||||
cy.intercept('/notifications/counts').as('countsRequest');
|
||||
cy.intercept('/notifications?i=i').as('notificationsRequest');
|
||||
|
||||
return [
|
||||
'@baseDataRequest',
|
||||
'@chatRequest',
|
||||
'@countsRequest',
|
||||
'@notificationsRequest',
|
||||
];
|
||||
const intercepts = ['@baseDataRequest', '@chatRequest'];
|
||||
|
||||
if (!url.includes('/notifications')) {
|
||||
cy.intercept('/notifications?i=i').as('notificationsRequest');
|
||||
cy.intercept('/notifications/counts').as('countsRequest');
|
||||
|
||||
intercepts.push('@notificationsRequest');
|
||||
intercepts.push('@countsRequest');
|
||||
}
|
||||
|
||||
return intercepts;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ module.exports = {
|
|||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 42,
|
||||
statements: 41,
|
||||
branches: 38,
|
||||
functions: 41,
|
||||
functions: 40,
|
||||
lines: 42,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ seeder.create_if_none(Organization) do
|
|||
remote_profile_image_url: logo = Faker::Company.logo,
|
||||
nav_image: logo,
|
||||
url: Faker::Internet.url,
|
||||
slug: "org#{rand(10_000)}",
|
||||
slug: "bachmanity",
|
||||
)
|
||||
|
||||
OrganizationMembership.create!(
|
||||
|
|
@ -220,6 +220,31 @@ chat_user_2 = seeder.create_if_doesnt_exist(User, "email", "chat-user-2@forem.lo
|
|||
user
|
||||
end
|
||||
|
||||
##############################################################################
|
||||
seeder.create_if_doesnt_exist(User, "email", "notifications-user@forem.com") do
|
||||
user = User.create!(
|
||||
name: "Notifications User",
|
||||
email: "notifications-user@forem.local",
|
||||
username: "notifications_user",
|
||||
summary: Faker::Lorem.paragraph_by_chars(number: 199, supplemental: false),
|
||||
profile_image: File.open(Rails.root.join("app/assets/images/#{rand(1..40)}.png")),
|
||||
website_url: Faker::Internet.url,
|
||||
confirmed_at: Time.current,
|
||||
password: "password",
|
||||
password_confirmation: "password",
|
||||
saw_onboarding: true,
|
||||
checked_code_of_conduct: true,
|
||||
checked_terms_and_conditions: true,
|
||||
)
|
||||
user.notification_setting.update(
|
||||
email_comment_notifications: false,
|
||||
email_follower_notifications: false,
|
||||
)
|
||||
|
||||
follow = admin_user.follows.create!(followable: user)
|
||||
Notification.send_new_follower_notification_without_delay(follow)
|
||||
end
|
||||
|
||||
##############################################################################
|
||||
|
||||
seeder.create_if_doesnt_exist(ChatChannel, "channel_name", "test chat channel") do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue