Migrate initializeArticleDate and initializeArticleReactions to Webpack (#19377)

* feat: tests for series list on article page

* feat: test for jump to comments button (needs fix)

* feat: tests for reaction drawer on article page

* feat: test for full date on hover??

* feat: add full date on hover tests for all pages with publish dates

* fixed jumping to comments test

* move initialisers to packs

* fix coverage issues

* fix flaky login modal spec

* switch back to using onclick instead of addEventListener
This commit is contained in:
PJ 2023-04-25 18:33:43 +01:00 committed by GitHub
parent 675ad72a9a
commit 800ff397e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 590 additions and 114 deletions

View file

@ -2,7 +2,6 @@
global initializeLocalStorageRender, initializeBodyData,
initializeAllTagEditButtons, initializeUserFollowButts,
initializeCommentsPage,
initializeArticleDate, initializeArticleReactions,
initializeSettings, initializeRuntimeBanner,
initializeTimeFixer, initializeCreditsPage,
initializeOnboardingTaskCard,
@ -14,8 +13,6 @@
function callInitializers() {
initializePaymentPointers();
initializeCommentsPage();
initializeArticleDate();
initializeArticleReactions();
initializeSettings();
initializeTimeFixer();
initializeCreditsPage();

View file

@ -1,10 +0,0 @@
/* Show article date/time according to user's locale */
/* global addLocalizedDateTimeToElementsTitles */
function initializeArticleDate() {
var articlesDates = document.querySelectorAll(
'.crayons-story time, article time, .single-other-article time',
);
addLocalizedDateTimeToElementsTitles(articlesDates, 'datetime');
}

View file

@ -1,15 +1,16 @@
/* global sendHapticMessage, showLoginModal, showModalAfterError, isTouchDevice, watchForLongTouch */
/* global sendHapticMessage, showLoginModal, isTouchDevice, watchForLongTouch */
import { showModalAfterError } from '../utilities/showUserAlertModal';
// Set reaction count to correct number
function setReactionCount(reactionName, newCount) {
var reactionButtons = document.getElementById(
'reaction-butt-' + reactionName,
const setReactionCount = (reactionName, newCount) => {
const reactionButtons = document.getElementById(
`reaction-butt-${reactionName}`,
).classList;
var reactionButtonCounter = document.getElementById(
'reaction-number-' + reactionName,
const reactionButtonCounter = document.getElementById(
`reaction-number-${reactionName}`,
);
var reactionEngagementCounter = document.getElementById(
'reaction_engagement_' + reactionName + '_count',
const reactionEngagementCounter = document.getElementById(
`reaction_engagement_${reactionName}_count`,
);
if (newCount > 0) {
reactionButtons.add('activated');
@ -25,30 +26,31 @@ function setReactionCount(reactionName, newCount) {
reactionEngagementCounter.parentElement.classList.add('hidden');
}
}
}
};
function setSumReactionCount(counts) {
let totalCountObj = document.getElementById('reaction_total_count');
const setSumReactionCount = (counts) => {
const totalCountObj = document.getElementById('reaction_total_count');
if (totalCountObj && counts.length > 2) {
let sum = 0;
for (let i in counts) {
if (counts[i]['category'] != 'readinglist') {
sum += counts[i]['count'];
for (const count of counts) {
if (count['category'] != 'readinglist') {
sum += count['count'];
}
}
totalCountObj.textContent = sum;
}
}
function showCommentCount() {
let commentCountObj = document.getElementById('reaction-number-comment');
};
const showCommentCount = () => {
const commentCountObj = document.getElementById('reaction-number-comment');
if (commentCountObj && commentCountObj.dataset.count) {
commentCountObj.textContent = commentCountObj.dataset.count;
}
}
};
function showUserReaction(reactionName, animatedClass) {
const showUserReaction = (reactionName, animatedClass) => {
const reactionButton = document.getElementById(
'reaction-butt-' + reactionName,
`reaction-butt-${reactionName}`,
);
reactionButton.classList.add('user-activated', animatedClass);
reactionButton.setAttribute('aria-pressed', 'true');
@ -63,11 +65,11 @@ function showUserReaction(reactionName, animatedClass) {
}
reactionDrawerButton.classList.add('user-activated', 'user-animated');
}
};
function hideUserReaction(reactionName) {
const hideUserReaction = (reactionName) => {
const reactionButton = document.getElementById(
'reaction-butt-' + reactionName,
`reaction-butt-${reactionName}`,
);
reactionButton.classList.remove('user-activated', 'user-animated');
reactionButton.setAttribute('aria-pressed', 'false');
@ -80,31 +82,31 @@ function hideUserReaction(reactionName) {
if (userActivatedReactions.length == 0) {
reactionDrawerButton.classList.remove('user-activated', 'user-animated');
}
}
};
function hasUserReacted(reactionName) {
const hasUserReacted = (reactionName) => {
return document
.getElementById('reaction-butt-' + reactionName)
.getElementById(`reaction-butt-${reactionName}`)
.classList.contains('user-activated');
}
};
function getNumReactions(reactionName) {
const reactionEl = document.getElementById('reaction-number-' + reactionName);
const getNumReactions = (reactionName) => {
const reactionEl = document.getElementById(`reaction-number-${reactionName}`);
if (!reactionEl || reactionEl.textContent === '') {
return 0;
}
return parseInt(reactionEl.textContent, 10);
}
};
function reactToArticle(articleId, reaction) {
var reactionTotalCount = document.getElementById('reaction_total_count');
const reactToArticle = (articleId, reaction) => {
const reactionTotalCount = document.getElementById('reaction_total_count');
var isReadingList = reaction === 'readinglist';
const isReadingList = reaction === 'readinglist';
// Visually toggle the reaction
function toggleReaction() {
var currentNum = getNumReactions(reaction);
const currentNum = getNumReactions(reaction);
if (hasUserReacted(reaction)) {
hideUserReaction(reaction);
setReactionCount(reaction, currentNum - 1);
@ -119,7 +121,7 @@ function reactToArticle(articleId, reaction) {
}
}
}
var userStatus = document.body.getAttribute('data-user-status');
const userStatus = document.body.getAttribute('data-user-status');
sendHapticMessage('medium');
if (userStatus === 'logged-out') {
showLoginModal({
@ -129,14 +131,14 @@ function reactToArticle(articleId, reaction) {
return;
}
toggleReaction();
document.getElementById('reaction-butt-' + reaction).disabled = true;
document.getElementById(`reaction-butt-${reaction}`).disabled = true;
function createFormdata() {
/*
* What's not shown here is that "authenticity_token" is included in this formData.
* The logic can be seen in sendFetch.js.
*/
var formData = new FormData();
const formData = new FormData();
formData.append('reactable_type', 'Article');
formData.append('reactable_id', articleId);
formData.append('category', reaction);
@ -148,81 +150,79 @@ function reactToArticle(articleId, reaction) {
.then((response) => {
if (response.status === 200) {
return response.json().then(() => {
document.getElementById('reaction-butt-' + reaction).disabled = false;
document.getElementById(`reaction-butt-${reaction}`).disabled = false;
});
} else {
toggleReaction();
document.getElementById('reaction-butt-' + reaction).disabled = false;
showModalAfterError({
response,
element: 'reaction',
action_ing: 'updating',
action_past: 'updated',
});
return undefined;
}
})
.catch((error) => {
toggleReaction();
document.getElementById('reaction-butt-' + reaction).disabled = false;
document.getElementById(`reaction-butt-${reaction}`).disabled = false;
showModalAfterError({
response,
element: 'reaction',
action_ing: 'updating',
action_past: 'updated',
});
return undefined;
})
.catch((_error) => {
toggleReaction();
document.getElementById(`reaction-butt-${reaction}`).disabled = false;
});
}
};
function setCollectionFunctionality() {
const setCollectionFunctionality = () => {
if (document.getElementById('collection-link-inbetween')) {
var inbetweenLinks = document.getElementsByClassName(
const inbetweenLinks = document.getElementsByClassName(
'series-switcher__link--inbetween',
);
var inbetweenLinksLength = inbetweenLinks.length;
for (var i = 0; i < inbetweenLinks.length; i += 1) {
const inbetweenLinksLength = inbetweenLinks.length;
for (let i = 0; i < inbetweenLinks.length; i += 1) {
inbetweenLinks[i].onclick = (e) => {
e.preventDefault();
var els = document.getElementsByClassName(
const els = document.getElementsByClassName(
'series-switcher__link--hidden',
);
var elsLength = els.length;
for (var j = 0; j < elsLength; j += 1) {
const elsLength = els.length;
for (let j = 0; j < elsLength; j += 1) {
els[0].classList.remove('series-switcher__link--hidden');
}
for (var k = 0; k < inbetweenLinksLength; k += 1) {
for (let k = 0; k < inbetweenLinksLength; k += 1) {
inbetweenLinks[0].className = 'series-switcher__link--hidden';
}
};
}
}
}
};
function requestReactionCounts(articleId) {
var ajaxReq;
ajaxReq = new XMLHttpRequest();
const requestReactionCounts = (articleId) => {
const ajaxReq = new XMLHttpRequest();
ajaxReq.onreadystatechange = () => {
if (ajaxReq.readyState === XMLHttpRequest.DONE) {
var json = JSON.parse(ajaxReq.response);
const json = JSON.parse(ajaxReq.response);
setSumReactionCount(json.article_reaction_counts);
showCommentCount();
json.article_reaction_counts.forEach((reaction) => {
setReactionCount(reaction.category, reaction.count);
});
json.reactions.forEach((reaction) => {
if (document.getElementById('reaction-butt-' + reaction.category)) {
if (document.getElementById(`reaction-butt-${reaction.category}`)) {
showUserReaction(reaction.category, 'not-user-animated');
}
});
}
};
ajaxReq.open('GET', '/reactions?article_id=' + articleId, true);
ajaxReq.open('GET', `/reactions?article_id=${articleId}`, true);
ajaxReq.send();
}
};
function openDrawerOnHover() {
var timer;
const openDrawerOnHover = () => {
let timer;
const drawerTrigger = document.getElementById('reaction-drawer-trigger');
if (!drawerTrigger) {
return;
}
drawerTrigger.addEventListener('click', function (event) {
var articleId = document.getElementById('article-body').dataset.articleId;
drawerTrigger.addEventListener('click', (_event) => {
const { articleId } = document.getElementById('article-body').dataset;
reactToArticle(articleId, 'like');
drawerTrigger.parentElement.classList.add('open');
@ -230,31 +230,31 @@ function openDrawerOnHover() {
if (isTouchDevice()) {
watchForLongTouch(drawerTrigger);
drawerTrigger.addEventListener('longTouch', function () {
drawerTrigger.addEventListener('longTouch', () => {
drawerTrigger.parentElement.classList.add('open');
});
document.addEventListener('touchstart', function (event) {
document.addEventListener('touchstart', (event) => {
if (!drawerTrigger.parentElement.contains(event.target)) {
drawerTrigger.parentElement.classList.remove('open');
}
});
} else {
document.querySelectorAll('.hoverdown').forEach(function (el) {
el.addEventListener('mouseover', function (event) {
document.querySelectorAll('.hoverdown').forEach((el) => {
el.addEventListener('mouseover', function (_event) {
this.classList.add('open');
clearTimeout(timer);
});
el.addEventListener('mouseout', function (event) {
timer = setTimeout(function (event) {
el.addEventListener('mouseout', (_event) => {
timer = setTimeout((_event) => {
document.querySelector('.hoverdown.open').classList.remove('open');
}, 500);
});
});
}
}
};
function closeDrawerOnOutsideClick() {
document.addEventListener('click', function (event) {
const closeDrawerOnOutsideClick = () => {
document.addEventListener('click', (event) => {
const reactionDrawerElement = document.querySelector('.reaction-drawer');
const reactionDrawerTriggerElement = document.querySelector(
'#reaction-drawer-trigger',
@ -270,44 +270,46 @@ function closeDrawerOnOutsideClick() {
}
}
});
}
};
function initializeArticleReactions() {
const initializeArticleReactions = () => {
setCollectionFunctionality();
openDrawerOnHover();
closeDrawerOnOutsideClick();
setTimeout(() => {
var reactionButts = document.getElementsByClassName('crayons-reaction');
const reactionButts = document.getElementsByClassName('crayons-reaction');
// we wait for the article to appear,
// we also check that reaction buttons are there as draft articles don't have them
if (document.getElementById('article-body') && reactionButts.length > 0) {
var articleId = document.getElementById('article-body').dataset.articleId;
const { articleId } = document.getElementById('article-body').dataset;
requestReactionCounts(articleId);
for (var i = 0; i < reactionButts.length; i += 1) {
for (let i = 0; i < reactionButts.length; i += 1) {
if (reactionButts[i].classList.contains('pseudo-reaction')) {
continue;
}
reactionButts[i].onclick = function addReactionOnClick(e) {
reactionButts[i].onclick = function addReactionOnClick(_event) {
reactToArticle(articleId, this.dataset.category);
};
}
}
var jumpToCommentsButt = document.getElementById('reaction-butt-comment');
var commentsSection = document.getElementById('comments');
const jumpToCommentsButt = document.getElementById('reaction-butt-comment');
const commentsSection = document.getElementById('comments');
if (
document.getElementById('article-body') &&
commentsSection &&
jumpToCommentsButt
) {
jumpToCommentsButt.onclick = function jumpToComments(e) {
jumpToCommentsButt.onclick = function jumpToComments(_event) {
commentsSection.scrollIntoView({ behavior: 'smooth' });
};
}
}, 3);
}
};
initializeArticleReactions();

View file

@ -0,0 +1,12 @@
/* Show article date/time according to user's locale */
import { addLocalizedDateTimeToElementsTitles } from '../utilities/localDateTime';
const initializeArticleDate = () => {
const articlesDates = document.querySelectorAll(
'.crayons-story time, article time',
);
addLocalizedDateTimeToElementsTitles(articlesDates, 'datetime');
};
initializeArticleDate();

View file

@ -103,5 +103,5 @@
<%= render "articles/sidebar_additional" %>
</div>
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", "feedPreviewCards", "heroBannerClose", defer: true %>
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", "feedPreviewCards", "heroBannerClose", "localizeArticleDates", defer: true %>
<% end %>

View file

@ -231,7 +231,7 @@
<%= render "moderations/modals/unflag_user_modal" %>
<div class="fullscreen-code js-fullscreen-code"></div>
<%= javascript_packs_with_chunks_tag "followButtons", "billboard", defer: true %>
<%= javascript_packs_with_chunks_tag "followButtons", "billboard", "localizeArticleDates", "articleReactions", defer: true %>
<% cache("article-show-scripts", expires_in: 8.hours) do %>
<script>

View file

@ -19,7 +19,7 @@
<% @articles.each do |article| %>
<%= render "articles/single_story", story: article.decorate, featured: article.main_image.present? %>
<% end %>
<%= javascript_packs_with_chunks_tag "followButtons", "feedPreviewCards", "hideBookmarkButtons", defer: true %>
<%= javascript_packs_with_chunks_tag "followButtons", "feedPreviewCards", "hideBookmarkButtons", "localizeArticleDates", defer: true %>
<% else %>
<p><%= t("views.series.list.empty") %></p>
<% end %>

View file

@ -4,13 +4,13 @@
<%= render "users/meta" %>
<% end %>
<%= render "organizations/header" %>
<div
<div
class="crayons-layout crayons-layout--limited-l crayons-layout--2-cols crayons-layout--2-cols--1-2 pt-4 m:pt-0"
id="index-container"
data-params="<%= params.merge(sort_by: "published_at", sort_direction: "desc", organization_id: @organization.id).to_json(only: %i[tag organization_id username q sort_by sort_direction]) %>" data-which="<%= @list_of %>"
data-feed="<%= params[:timeframe] || "base-feed" %>"
data-articles-since="<%= Timeframe.datetime_iso8601(params[:timeframe]) %>">
<div class="crayons-layout__sidebar-left crayons-layout__content">
<%= render "organizations/sidebar" %>
</div>
@ -29,4 +29,4 @@
</main>
</div>
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", "hideBookmarkButtons", defer: true %>
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", "hideBookmarkButtons", "localizeArticleDates", defer: true %>

View file

@ -130,5 +130,5 @@
<%= render "stories/tagged_articles/sidebar_additional" %>
</div>
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", "feedPreviewCards", "hideBookmarkButtons", "drawerSliders", defer: true %>
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", "feedPreviewCards", "hideBookmarkButtons", "drawerSliders", "localizeArticleDates", defer: true %>
<% end %>

View file

@ -145,4 +145,4 @@
</div>
</main>
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", "feedPreviewCards", "hideBookmarkButtons", "profileDropdown", "users/profilePage", defer: true %>
<%= javascript_packs_with_chunks_tag "storiesList", "followButtons", "feedPreviewCards", "hideBookmarkButtons", "profileDropdown", "users/profilePage", "localizeArticleDates", defer: true %>

View file

@ -0,0 +1,72 @@
describe('Series article list on article page', () => {
const createSeriesArticle = (title) => {
return cy.createArticle({
title,
content: `${title} in New Series`,
series: 'New Series',
published: true,
});
};
const findVisibleLink = (innerText, { as = 'nothing' } = {}) => {
cy.findByText(innerText).parent().as(as).should('have.attr', 'href');
};
beforeEach(() => {
cy.testSetup();
cy.fixture('users/seriesUser.json').as('user');
cy.get('@user')
.then((user) => cy.loginAndVisit(user, '/'))
.then(() => createSeriesArticle('First Post'))
.then(() => createSeriesArticle('Second Post'))
.then(() => createSeriesArticle('Third Post'))
.then(() => createSeriesArticle('Fourth Post'))
.then(() => createSeriesArticle('Fifth Post'))
.then((response) => {
cy.visitAndWaitForUserSideEffects(response.body.current_state_path);
});
});
context('when there are 5 articles or less', () => {
it('shows links to all of them', () => {
cy.get('nav.series-switcher').within(() => {
findVisibleLink('First Post');
findVisibleLink('Second Post');
findVisibleLink('Third Post');
findVisibleLink('Fourth Post');
findVisibleLink('Fifth Post');
});
});
});
context('when there are more than 5 articles', () => {
beforeEach(() => {
createSeriesArticle('Sixth Post').then((response) => {
cy.visitAndWaitForUserSideEffects(response.body.current_state_path);
});
});
it('hides the middle articles behind an expander', () => {
cy.get('nav.series-switcher').within(() => {
findVisibleLink('First Post', { as: 'firstPostLink' });
findVisibleLink('Second Post', { as: 'secondPostLink' });
cy.findByText('Third Post', { hidden: true }).should('not.be.visible');
cy.findByText('Fourth Post', { hidden: true }).should('not.be.visible');
findVisibleLink('Fifth Post', { as: 'fifthPostLink' });
findVisibleLink('Sixth Post', { as: 'sixthPostLink' });
findVisibleLink('2 more parts...', { as: 'expander' });
cy.get('@expander').click();
cy.get('@firstPostLink').should('be.visible');
cy.get('@secondPostLink').should('be.visible');
findVisibleLink('Third Post');
findVisibleLink('Fourth Post');
cy.get('@fifthPostLink').should('be.visible');
cy.get('@sixthPostLink').should('be.visible');
cy.get('@expander').should('not.be.visible');
});
});
});
});

View file

@ -0,0 +1,225 @@
describe('Post reactions drawer', () => {
const findHiddenButton = (label, { as = 'nothing' } = {}) => {
cy.findByRole('button', { name: label, hidden: true })
.as(as)
.should('not.be.visible');
};
const findAllReactionButtons = () => {
findHiddenButton('Like', { as: 'heartReaction' });
findHiddenButton('Unicorn', { as: 'unicornReaction' });
findHiddenButton('Exploding Head', { as: 'headReaction' });
findHiddenButton('Raised Hands', { as: 'handsReaction' });
findHiddenButton('Fire', { as: 'fireReaction' });
return [
'@heartReaction',
'@unicornReaction',
'@headReaction',
'@handsReaction',
'@fireReaction',
];
};
const checkReactions = (variable, { count = 0 } = {}) => {
cy.get(variable).within(() => {
cy.get('.crayons-reaction__count').should('have.text', `${count}`);
});
};
beforeEach(() => {
cy.testSetup();
cy.fixture('users/articleEditorV2User.json').as('user');
cy.get('@user').then((user) => {
cy.loginUser(user).then(() => {
cy.createArticle({
title: 'Test Article',
tags: ['python'],
content: `This is an article about ball pythons.`,
published: true,
}).then((response) => {
cy.visitAndWaitForUserSideEffects(response.body.current_state_path);
});
});
});
});
context('on desktop', () => {
beforeEach(() => {
cy.viewport('macbook-16');
});
it('should open and close on hover', () => {
const reactions = findAllReactionButtons();
cy.findByRole('button', { name: 'reaction-drawer-trigger' })
.as('reactionDrawerButton')
.trigger('mouseover');
for (const reaction of reactions) {
cy.get(reaction).should('be.visible');
}
cy.get('@reactionDrawerButton').trigger('mouseout');
for (const reaction of reactions) {
cy.get(reaction).should('not.be.visible');
}
});
});
context('on mobile', () => {
beforeEach(() => {
cy.url().then((url) => {
cy.visitOnMobile(url);
});
});
it('should open on long press and close on a tap outside it', () => {
const reactions = findAllReactionButtons();
// Dismiss deep link banner
cy.findByRole('button', { name: 'Dismiss banner' }).click();
cy.findByRole('button', { name: 'reaction-drawer-trigger' })
.as('reactionDrawerButton')
.trigger('touchstart');
for (const reaction of reactions) {
cy.get(reaction).should('be.visible');
}
cy.get('@reactionDrawerButton').trigger('touchend');
for (const reaction of reactions) {
cy.get(reaction).should('be.visible');
}
});
});
it('should act as a like button when clicked', () => {
cy.intercept('POST', '/reactions').as('reactRequest');
findHiddenButton('Like', { as: 'heartReaction' });
checkReactions('@heartReaction', { count: 0 });
cy.findByRole('button', { name: 'reaction-drawer-trigger' }).as(
'reactionDrawerButton',
);
checkReactions('@reactionDrawerButton', { count: 0 });
cy.get('@reactionDrawerButton').click();
cy.wait('@reactRequest');
checkReactions('@heartReaction', { count: 1 });
checkReactions('@reactionDrawerButton', { count: 1 });
cy.get('@reactionDrawerButton').click();
cy.wait('@reactRequest');
checkReactions('@heartReaction', { count: 0 });
checkReactions('@reactionDrawerButton', { count: 0 });
});
it('should contain working reaction buttons', () => {
cy.intercept('POST', '/reactions').as('reactRequest');
cy.findByRole('button', { name: 'reaction-drawer-trigger' })
.as('reactionDrawerButton')
.trigger('mouseover');
checkReactions('@reactionDrawerButton', { count: 0 });
cy.findByRole('button', { name: 'Like' }).as('heartReaction').click();
cy.wait('@reactRequest');
checkReactions('@heartReaction', { count: 1 });
checkReactions('@reactionDrawerButton', { count: 1 });
cy.findByRole('button', { name: 'Unicorn' }).as('unicornReaction').click();
cy.wait('@reactRequest');
checkReactions('@unicornReaction', { count: 1 });
checkReactions('@reactionDrawerButton', { count: 2 });
cy.findByRole('button', { name: 'Exploding Head' })
.as('headReaction')
.click();
cy.wait('@reactRequest');
checkReactions('@headReaction', { count: 1 });
checkReactions('@reactionDrawerButton', { count: 3 });
cy.findByRole('button', { name: 'Raised Hands' })
.as('handsReaction')
.click();
cy.wait('@reactRequest');
checkReactions('@handsReaction', { count: 1 });
checkReactions('@reactionDrawerButton', { count: 4 });
cy.findByRole('button', { name: 'Fire' }).as('fireReaction').click();
cy.wait('@reactRequest');
checkReactions('@fireReaction', { count: 1 });
checkReactions('@reactionDrawerButton', { count: 5 });
const reactions = [
'@heartReaction',
'@unicornReaction',
'@headReaction',
'@handsReaction',
'@fireReaction',
];
let totalCount = 5;
// Test un-reacting
for (const reaction of reactions) {
cy.get(reaction).click();
cy.wait('@reactRequest');
totalCount -= 1;
checkReactions(reaction, { count: 0 });
checkReactions('@reactionDrawerButton', { count: totalCount });
}
});
context('when reacting fails', () => {
// For UX reasons the UI shows a "successful" reaction before the actual request
// to create the reaction returns from the server
it('should revert the reaction if the user is offline', () => {
cy.intercept('POST', '/reactions', { forceNetworkError: true }).as(
'reactRequest',
);
cy.findByRole('button', { name: 'reaction-drawer-trigger' })
.as('reactionDrawerButton')
.trigger('mouseover');
cy.findByRole('button', { name: 'Unicorn' }).as('unicornReaction');
checkReactions('@unicornReaction', { count: 0 });
checkReactions('@reactionDrawerButton', { count: 0 });
cy.get('@unicornReaction').click();
cy.wait('@reactRequest');
checkReactions('@unicornReaction', { count: 0 });
checkReactions('@reactionDrawerButton', { count: 0 });
});
it('should also notify the user', () => {
cy.intercept('POST', '/reactions', { statusCode: 404 }).as(
'reactRequest',
);
cy.findByRole('button', { name: 'reaction-drawer-trigger' })
.as('reactionDrawerButton')
.trigger('mouseover');
cy.findByRole('button', { name: 'Unicorn' }).as('unicornReaction');
checkReactions('@unicornReaction', { count: 0 });
checkReactions('@reactionDrawerButton', { count: 0 });
cy.get('@unicornReaction').click();
cy.wait('@reactRequest');
cy.findByRole('heading', { name: 'Error updating reaction' }).should(
'be.visible',
);
checkReactions('@unicornReaction', { count: 0 });
checkReactions('@reactionDrawerButton', { count: 0 });
});
});
});

View file

@ -8,7 +8,8 @@ describe('Post sidebar actions', () => {
cy.createArticle({
title: 'Test Article',
tags: ['beginner', 'ruby', 'go'],
content: `This is a test article's contents.`,
// Generating a really long article so that the 'Jump to Comments' effect is more visible
content: `This is a test article's contents.\n\n`.repeat(100),
published: true,
}).then((response) => {
cy.visitAndWaitForUserSideEffects(response.body.current_state_path);
@ -74,4 +75,29 @@ describe('Post sidebar actions', () => {
cy.get('@dropdownButton').click();
cy.findByText('Copied to Clipboard').should('not.be.visible');
});
it('should jump to comments when the button is pressed', () => {
cy.findByRole('heading', { name: 'Test Article' })
.as('articleHeader')
.should('be.within_viewport');
cy.findByRole('button', { name: 'Sort comments' })
.as('commentsSortDropdown')
.should('not.be.within_viewport');
// This stub is necessary because somehow, Cypress does not support smooth
// scrolling. See https://github.com/cypress-io/cypress/issues/3200
cy.get('#comments').then(($comments) => {
const comments = $comments[0];
const originalScroll = comments.scrollIntoView.bind(comments);
cy.stub(comments, 'scrollIntoView').callsFake(() => originalScroll());
cy.findByRole('button', { name: 'Jump to Comments' })
.click()
.then(() => {
cy.get('@commentsSortDropdown').should('be.within_viewport');
cy.get('@articleHeader').should('not.be.within_viewport');
});
});
});
});

View file

@ -0,0 +1,82 @@
describe('Hovering on article publish date', () => {
const checkFullDateOnHover = (selector, { locale = '' }) => {
cy.get(selector).should((elements) => {
elements.each((_, element) => {
const date = new Date(element.getAttribute('datetime'));
const formattedDate = new Intl.DateTimeFormat(locale, {
weekday: 'long',
month: 'long',
day: 'numeric',
}).format(date);
expect(element.getAttribute('title')).contains(formattedDate);
});
});
};
beforeEach(() => {
cy.testSetup();
});
it('shows full date on the logged-out home page', () => {
for (const locale of ['en-US', 'fr-FR', 'es-ES']) {
cy.visitWithLocale('/', locale);
checkFullDateOnHover('.crayons-story__meta time', { locale });
}
});
it('shows full date on the tagged articles page', () => {
for (const locale of ['en-US', 'fr-FR', 'es-ES']) {
cy.visitWithLocale('/t/tag1', locale);
checkFullDateOnHover('.crayons-story__meta time', { locale });
}
});
it('shows full date on an article page', () => {
for (const locale of ['en-US', 'fr-FR', 'es-ES']) {
cy.visitWithLocale('/admin_mcadmin/test-article-slug', locale);
checkFullDateOnHover('.crayons-story__meta time', { locale });
}
});
it('shows full date on user and organisation profile pages', () => {
for (const locale of ['en-US', 'fr-FR', 'es-ES']) {
cy.visitWithLocale('/admin_mcadmin', locale);
checkFullDateOnHover('.crayons-story__meta time', { locale });
cy.findByRole('link', { name: 'Bachmanity' }).click();
checkFullDateOnHover('.crayons-story__meta time', { locale });
}
});
context('on a series list page', () => {
beforeEach(() => {
cy.fixture('users/seriesUser.json').as('user');
cy.get('@user')
.then((user) => cy.loginUser(user))
.then(() =>
cy.createArticle({
title: 'Second Test Post',
content: 'Some more content so the series switcher shows up',
series: 'seriestest',
published: true,
}),
)
.then((response) => {
cy.visitAndWaitForUserSideEffects(response.body.current_state_path);
});
});
it('shows full date', () => {
cy.findByRole('link', { name: /^seriestest/ }).click();
cy.findByRole('heading', { name: "seriestest Series' Articles" });
cy.url().then((url) => {
for (const locale of ['en-US', 'fr-FR', 'es-ES']) {
cy.visitWithLocale(url, locale);
checkFullDateOnHover('.crayons-story__meta time', { locale });
}
});
});
});
});

View file

@ -30,7 +30,14 @@ describe('Show log in modal', () => {
it('should show login modal for article reaction clicks', () => {
cy.findAllByText('Test article').last().click();
cy.findByLabelText('reaction-drawer-trigger').last().trigger('mouseover');
// Wait for reactions' async setup to complete/show the reaction counts
cy.findByLabelText('reaction-drawer-trigger')
.as('reactionDrawerButton')
.within(() => {
cy.get('.crayons-reaction__count').should('have.text', '0');
});
cy.get('@reactionDrawerButton').trigger('mouseover');
cy.findByRole('button', { name: 'Like' }).as('heartReaction');
cy.findByRole('button', { name: 'Unicorn' }).as('unicornReaction');
cy.findByRole('button', { name: 'Add to reading list' }).as(

View file

@ -0,0 +1,24 @@
function assertions(_chai, _utils) {
_chai.Assertion.addMethod('within_viewport', function withinViewport() {
const elements = this._obj;
cy.window().then((window) => {
const viewportRight = window.innerWidth;
const viewportBottom = window.innerHeight;
const bounds = elements[0].getBoundingClientRect();
this.assert(
bounds.top < viewportBottom &&
bounds.bottom > 0 &&
bounds.left < viewportRight &&
bounds.right > 0,
'expected #{this} to be within the viewport',
'expected #{this} to not be within the viewport',
this._obj,
);
});
});
}
chai.use(assertions);

View file

@ -99,6 +99,42 @@ Cypress.Commands.add('loginUser', ({ email, password }) => {
);
});
/**
* Visits a URL with the specified locale. Useful for localisation testing.
*
* @param url {string} The url to visit
* @param locale {string} A valid locale to mock
*/
Cypress.Commands.add('visitWithLocale', (url, locale) => {
cy.visit(url, {
onBeforeLoad: (window) => {
Object.defineProperty(window.navigator, 'language', { value: locale });
Object.defineProperty(window.navigator, 'languages', {
value: [locale],
});
},
});
});
/**
* Visits a URL as a "proper" mobile device, mocking the user agent and not just
* the viewport.
*
* @param url {string} The url to visit
*/
Cypress.Commands.add('visitOnMobile', (url) => {
cy.viewport('iphone-x');
cy.visit(url, {
onBeforeLoad: (window) => {
Object.defineProperty(window.navigator, 'userAgent', {
value:
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1',
});
Object.defineProperty(window.navigator, 'platform', { value: 'iPhone' });
},
});
});
/**
* Logs in a creator with the given name, username, email, and password.
*

View file

@ -19,6 +19,9 @@ import '@testing-library/cypress/add-commands';
import 'cypress-file-upload';
import 'cypress-failed-log';
// Custom assertions
import './assertions';
// Import commands.js using ES2015 syntax:
import './commands';