Follow and Hidden Buttons on /tags page (#20005)

This commit is contained in:
Ridhwana 2023-08-30 14:29:00 +02:00 committed by GitHub
parent 02db638d9d
commit f4a9c192c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 252 additions and 12 deletions

View file

@ -0,0 +1,90 @@
import { h, render } from 'preact';
import { getUserDataAndCsrfToken } from '@utilities/getUserDataAndCsrfToken';
/* global showLoginModal */
function renderPage(currentUser) {
import('../tags/Tag')
.then(({ Tag }) => {
const tagCards = document.getElementsByClassName('tag-card');
const followedTags = JSON.parse(currentUser.followed_tags);
Array.from(tagCards).forEach((element) => {
const followedTag = followedTags.find(
(tag) => tag.id == element.dataset.tagId,
);
const following = followedTag?.points >= 0;
const hidden = followedTag?.points < 0;
render(
<Tag
id={element.dataset.tagId}
name={element.dataset.tagName}
isFollowing={following || hidden}
isHidden={hidden}
/>,
document.getElementById(`tag-buttons-${element.dataset.tagId}`),
);
});
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Unable to load tags', error);
});
}
/**
* Adds an event listener to the inner page content, to handle any and all follow button clicks with a single handler
*/
function listenForButtonClicks() {
document
.getElementById('page-content-inner')
.addEventListener('click', handleButtonClick);
}
function handleButtonClick({ target }) {
let trigger;
if (target.classList.contains('follow-action-button')) {
trigger = 'follow_button';
}
if (target.classList.contains('hide-action-button')) {
trigger = 'hide_button';
}
if (trigger) {
const trackingData = {
referring_source: 'tag',
trigger,
};
showLoginModal(trackingData);
}
}
document.ready.then(() => {
const userStatus = document.body.getAttribute('data-user-status');
if (userStatus === 'logged-out') {
listenForButtonClicks();
return;
}
getUserDataAndCsrfToken()
.then(({ currentUser, csrfToken }) => {
window.currentUser = currentUser;
window.csrfToken = csrfToken;
renderPage(currentUser);
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Error getting user and CSRF Token', error);
});
});
Document.prototype.ready = new Promise((resolve) => {
if (document.readyState !== 'loading') {
return resolve();
}
document.addEventListener('DOMContentLoaded', () => resolve());
return null;
});

View file

@ -0,0 +1,97 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import PropTypes from 'prop-types';
/* global browserStoreCache */
/**
* Renders the updated buttons component for a given tag card
* @param {number} props.id The id of the tag
* @param {string} props.name The name of the tag
* @param {boolean} props.isFollowing Whether the user is following the tag
* @param {string} props.isHidden Whether the tag is hidden
*
* @returns Updates the given Tag buttons (Follow and Hide) with the correct labels, buttons and actions.
*/
export const Tag = ({ id, name, isFollowing, isHidden }) => {
const [following, setFollowing] = useState(isFollowing);
const [hidden, setHidden] = useState(isHidden);
let followingButton;
const toggleFollowButton = () => {
setFollowing(!following);
browserStoreCache('remove');
postFollowItem({ following: !following, hidden });
};
const toggleHideButton = () => {
setHidden(!hidden);
const updatedFollowing = true;
setFollowing(updatedFollowing);
browserStoreCache('remove');
postFollowItem({ hidden: !hidden, following: updatedFollowing });
};
const postFollowItem = ({ following, hidden }) => {
fetch('/follows', {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
followable_type: 'Tag',
followable_id: id,
verb: `${following ? '' : 'un'}follow`,
explicit_points: hidden ? -1 : 1,
}),
credentials: 'same-origin',
}).then((response) => {
if (response.status !== 200) {
// TODO: replace this with an actual modal
alert('An error occurred');
}
});
};
const hideButtonLabel = hidden ? 'Unhide' : 'Hide';
const followButtonLabel = following ? 'Following' : 'Follow';
if (!hidden) {
followingButton = (
<button
onClick={toggleFollowButton}
className={`crayons-btn ${
following ? 'crayons-btn--outlined' : 'crayons-btn--primary'
}`}
aria-pressed={following}
aria-label={`${followButtonLabel} tag: ${name}`}
>
{followButtonLabel}
</button>
);
}
return (
<div>
{followingButton}
<button
onClick={toggleHideButton}
className={`crayons-btn ${
hidden ? 'crayons-btn--danger' : 'crayons-btn--ghost'
}`}
aria-label={`${hideButtonLabel} tag: ${name}`}
>
{hideButtonLabel}
</button>
</div>
);
};
Tag.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
following: PropTypes.bool.isRequired,
hidden: PropTypes.bool.isRequired,
};

View file

@ -31,6 +31,7 @@
<form accept-charset="UTF-8" method="get" action="<%= tags_path %>" role="search">
<div class="crayons-fields crayons-fields--horizontal">
<%= link_to t("views.tags.following_tags"), dashboard_following_tags_path, class: "crayons-btn crayons-btn--ghost" %>
<%= link_to t("views.tags.hidden_tags"), dashboard_hidden_tags_path, class: "crayons-btn crayons-btn--ghost" %>
<div class="crayons-field flex-1 relative">
<input class="crayons-header--search-input crayons-textfield js-search-input" type="text" id="nav-search" aria-label="<%= t("views.tags.search.aria_labels.search_input") %>" name="q" placeholder="<%= t("views.tags.search.placeholder") %>" autocomplete="off" />
<button type="submit" aria-label="<%= t("views.tags.search.aria_labels.submit") %>" class="c-btn c-btn--icon-alone absolute inset-px left-auto mt-0 py-0">
@ -48,7 +49,7 @@
<% else %>
<div class="grid gap-3 m:gap-4 l:gap-4 m:grid-cols-2 l:grid-cols-4 px-3 m:px-0 pb-3" data-follow-button-container="true">
<% @tags.includes([:badge]).each do |tag| %>
<div class="tag-card crayons-card p-3 pt-2 m:p-5 m:pt-4 relative flex flex-col">
<div data-tag-id="<%= tag.id %>" data-tag-name="<%= tag.name %>" id="tag-<%= tag.id %>" class="tag-card crayons-card p-3 pt-2 m:p-5 m:pt-4 relative flex flex-col">
<div>
<div class="mb-1 flex items-center justify-between">
<h4 class="-ml-2">
@ -60,21 +61,28 @@
<p class="mb-6 fs-s color-base-70 truncate-at-3"><%= sanitize tag.short_summary %></p>
<% end %>
</div>
<% if tag %>
<div class="mt-auto flex items-end justify-between">
<div id="tag-buttons-<%= tag.id %>" class="mt-auto flex items-end justify-between">
<div>
<button
class="crayons-btn crayons-btn--primary follow-action-button"
data-info='<%= DataInfo.to_json(object: tag, class_name: "Tag", followStyle: "primary") %>'>
aria-label="<%= t("views.tags.aria_labels.follow", tag_name: tag.name) %>">
<%= t("views.tags.follow") %>
</button>
<% if tag.badge_id && tag.badge %>
<img src="<%= optimized_image_url(tag.badge.badge_image_url, width: 180) %>" class="tag-card__badge" alt="" loading="lazy" />
<% end %>
<button
class="crayons-btn crayons-btn--ghost hide-action-button"
aria-label="<%= t("views.tags.aria_labels.hide", tag_name: tag.name) %>">
<%= t("views.tags.hide") %>
</button>
</div>
<% end %>
<% if tag.badge_id && tag.badge %>
<img src="<%= optimized_image_url(tag.badge.badge_image_url, width: 180) %>" class="tag-card__badge" alt="" loading="lazy" />
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</main>
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>
<%= javascript_packs_with_chunks_tag "TagsIndex", defer: true %>

View file

@ -38,7 +38,12 @@ en:
placeholder: Optional - Can be used for challenges, i.e. submission guidelines.
submit: Save
follow: Follow
hide: Hide
aria_labels:
follow: "Follow tag: %{tag_name}"
hide: "Hide tag: %{tag_name}"
following_tags: Following tags
hidden_tags: Hidden tags
moderator:
text_html: "comma separated, space is optional. Example: %{code}"
code: 123

View file

@ -39,7 +39,12 @@ fr:
submit: Save
markdown_html: "%{tag} is the name of the tag used in Markdown."
follow: Suivre
aria_labels:
follow: "Follow tag: %{tag_name}"
hide: "Hide tag: %{tag_name}"
hide: Hide
following_tags: Sujets suivis
hidden_tags: Hidden tags
moderator:
text_html: "comma separated, space is optional. Example: %{code}"
code: 123

View file

@ -80,7 +80,6 @@ describe('Show log in modal', () => {
it('should show login modal for tag follow button click', () => {
cy.visit('/tags');
cy.findByRole('heading', { name: 'Tags' });
cy.get('[data-follow-clicks-initialized]');
verifyLoginModalBehavior(() =>
cy.findByRole('button', { name: 'Follow tag: tag1' }),
@ -94,6 +93,15 @@ describe('Show log in modal', () => {
);
});
it('should show login modal for tag hide button click', () => {
cy.visit('/tags');
cy.findByRole('heading', { name: 'Tags' });
verifyLoginModalBehavior(() =>
cy.findByRole('button', { name: 'Hide tag: tag1' }),
);
});
it('should show login modal for user profile follow button click', () => {
cy.visit('/admin_mcadmin');
cy.get('[data-follow-clicks-initialized]');

View file

@ -7,14 +7,13 @@ describe('Follow tag', () => {
cy.get('@user').then((user) => {
cy.loginAndVisit(user, '/tags').then(() => {
cy.findByRole('heading', { name: '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 tag: tag1' }).as('followButton');
cy.findByRole('button', { name: 'Follow tag: tag0' }).as('followButton');
cy.get('@followButton').click();
cy.wait('@followsRequest');
@ -28,6 +27,34 @@ describe('Follow tag', () => {
cy.get('@followButton').should('have.text', 'Follow');
cy.get('@followButton').should('have.attr', 'aria-pressed', 'false');
});
it('hides and unhides a tag', () => {
cy.intercept('/follows').as('followsRequest');
cy.findByRole('button', { name: 'Follow tag: tag0' }).as('followButton');
cy.findByRole('button', { name: 'Hide tag: tag0' }).as('hideButton');
cy.get('@hideButton').click();
cy.wait('@followsRequest');
// clicking on 'Hide' should change it to an 'Unhide'
// and remove the Follow button
cy.get('@hideButton').should('have.text', 'Unhide');
cy.get('@followButton').should('not.exist');
// clicking on 'Unhide' should change it back to 'Hide'
// and show a 'Following' button
cy.get('@hideButton').click();
cy.wait('@followsRequest');
cy.get('@hideButton').should('have.text', 'Hide');
cy.get('@followButton').should('not.exist');
cy.findByRole('button', { name: 'Following tag: tag0' }).as(
'followingButton',
);
cy.get('@followingButton').should('exist');
cy.get('@followingButton').should('have.text', 'Following');
cy.get('@followingButton').should('have.attr', 'aria-pressed', 'true');
});
});
describe('Tag feed page', () => {