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:
Suzanne Aitchison 2021-07-27 10:45:50 +01:00 committed by GitHub
parent 7c379c6d3b
commit 75b6c8ed96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 723 additions and 357 deletions

View file

@ -24,7 +24,6 @@ module.exports = {
browserStoreCache: false,
initializeBaseUserData: false,
initializeReadingListIcons: false,
initializeAllFollowButts: false,
initializeSponsorshipVisibility: false,
ActiveXObject: false,
AndroidBridge: false,

View file

@ -54,8 +54,6 @@ function initializePage() {
initializeAllTagEditButtons();
}
initializeBroadcast();
initializeAllFollowButts();
initializeUserFollowButts();
initializeReadingListIcons();
initializeSponsorshipVisibility();
if (document.getElementById('sidebar-additional')) {

View file

@ -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';
}
}

View file

@ -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) {

View file

@ -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',

View file

@ -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();

View file

@ -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();
};
/**

View 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();
});

View file

@ -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');

View file

@ -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>

View file

@ -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 %>

View file

@ -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>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -67,3 +67,4 @@
</div>
</div>
</div>
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -0,0 +1,5 @@
{
"username": "notifications_user",
"email": "notifications-user@forem.local",
"password": "password"
}

View 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');
});
});
});

View file

@ -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');
});
});

View 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' });
});
});

View 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');
});
});

View file

@ -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()

View file

@ -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' }));
});
});

View file

@ -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();

View file

@ -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');

View file

@ -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' });
});
});

View 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');
});
});
});

View file

@ -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);
}

View file

@ -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;
};

View file

@ -25,9 +25,9 @@ module.exports = {
],
coverageThreshold: {
global: {
statements: 42,
statements: 41,
branches: 38,
functions: 41,
functions: 40,
lines: 42,
},
},

View file

@ -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