Allow Moderator Role to Unpublish Single Post (#17795)

* relocate btn; build functionality for admin only

* remove commented code

* add explanatory comment

* refactor

* build unpublish and feature-post policies

* mod role in policies fails specs 😓

* fix failing specs

* add end-to-end tests

* fix hardcoded name in modal  😓
This commit is contained in:
Arit Amana 2022-06-03 12:48:20 -04:00 committed by GitHub
parent 3fb085b3d9
commit 1679fbf47d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 273 additions and 70 deletions

View file

@ -1,4 +1,5 @@
import { toggleFlagUserModal } from '../packs/flagUserModal';
import { toggleUnpublishPostModal } from '../packs/unpublishPostModal';
import { request } from '@utilities/http';
export function addCloseListener() {
@ -149,33 +150,6 @@ export async function updateExperienceLevel(
}
}
const adminUnpublishArticle = async (id, username, slug) => {
try {
const response = await request(`/articles/${id}/admin_unpublish`, {
method: 'PATCH',
body: JSON.stringify({ id, username, slug }),
credentials: 'same-origin',
});
const outcome = await response.json();
/* eslint-disable no-restricted-globals */
if (outcome.message == 'success') {
window.top.location.assign(`${window.location.origin}${outcome.path}`);
} else {
top.addSnackbarItem({
message: `Error: ${outcome.message}`,
addCloseButton: true,
});
}
} catch (error) {
top.addSnackbarItem({
message: `Error: ${error}`,
addCloseButton: true,
});
}
};
const adminFeatureArticle = async (id, featured) => {
try {
const response = await request(`/articles/${id}/admin_featured_toggle`, {
@ -415,17 +389,7 @@ export function addBottomActionsListeners() {
const unpublishArticleBtn = document.getElementById('unpublish-article-btn');
if (unpublishArticleBtn) {
unpublishArticleBtn.addEventListener('click', () => {
const {
articleId: id,
articleAuthor: username,
articleSlug: slug,
} = unpublishArticleBtn.dataset;
if (confirm('You are unpublishing this post; are you sure?')) {
adminUnpublishArticle(id, username, slug);
}
});
unpublishArticleBtn.addEventListener('click', toggleUnpublishPostModal);
}
document

View file

@ -6,13 +6,23 @@ getCsrfToken().then(() => {
if (articleContainer) {
const user = userData();
const { authorId: articleAuthorId, path } = articleContainer.dataset;
const {
articleId,
articleSlug,
authorId: articleAuthorId,
authorName,
authorUsername,
path,
} = articleContainer.dataset;
const initializeModerationsTools = async () => {
const { initializeActionsPanel } = await import(
'../actionsPanel/initializeActionsPanelToggle'
);
const { initializeFlagUserModal } = await import('./flagUserModal');
const { initializeUnpublishPostModal } = await import(
'./unpublishPostModal.jsx'
);
// If the user can moderate an article give them access to this panel.
// Note: this assumes we're within the article context (which is the case
@ -31,10 +41,17 @@ getCsrfToken().then(() => {
if (user?.id !== articleAuthorId && !isModerationPage()) {
initializeActionsPanel(user, path);
initializeFlagUserModal(articleAuthorId);
initializeUnpublishPostModal(
articleId,
authorName,
authorUsername,
articleSlug,
);
// "/mod" page
} else if (isModerationPage()) {
initializeActionsPanel(user, path);
initializeFlagUserModal(articleAuthorId);
initializeUnpublishPostModal(articleId, authorUsername, articleSlug);
}
}
};

View file

@ -0,0 +1,156 @@
import { h, render } from 'preact';
import PropTypes from 'prop-types';
import { request } from '../utilities/http';
import { ButtonNew as Button } from '@crayons';
import RemoveIcon from '@images/x.svg';
async function confirmAdminUnpublishPost(id, username, slug) {
try {
const response = await request(`/articles/${id}/admin_unpublish`, {
method: 'PATCH',
body: JSON.stringify({ id, username, slug }),
credentials: 'same-origin',
});
const outcome = await response.json();
/* eslint-disable no-restricted-globals */
if (outcome.message == 'success') {
window.top.location.assign(`${window.location.origin}${outcome.path}`);
} else {
top.addSnackbarItem({
message: `Error: ${outcome.message}`,
addCloseButton: true,
});
}
} catch (error) {
top.addSnackbarItem({
message: `Error: ${error}`,
addCloseButton: true,
});
}
toggleUnpublishPostModal();
}
/**
* Shows or hides the flag user modal.
*/
export function toggleUnpublishPostModal() {
const modalContainer = top.document.getElementsByClassName(
'unpublish-post-modal-container',
)[0];
modalContainer.classList.toggle('hidden');
if (!modalContainer.classList.contains('hidden')) {
top.window.scrollTo(0, 0);
top.document.body.style.height = '100vh';
top.document.body.style.overflowY = 'hidden';
} else {
top.document.body.style.height = 'inherit';
top.document.body.style.overflowY = 'inherit';
}
}
/**
* Initializes the Unpublish Post modal for the given article ID, author username and article slug.
*
* @param {number} articleId
* @param {string} authorUsername
* @param {string} articleSlug
*/
export function initializeUnpublishPostModal(
articleId,
authorName,
authorUsername,
articleSlug,
) {
// Check whether context is ModCenter or Friday-Night-Mode
const modContainer = document.getElementById('mod-container');
if (!modContainer) {
return;
}
render(
<UnpublishPostModal
articleId={articleId}
authorName={authorName}
authorUsername={authorUsername}
articleSlug={articleSlug}
/>,
document.getElementsByClassName('unpublish-post-modal-container')[0],
);
}
/**
* A modal for unpublishing a post. This can be used in the moderation center
* or on an article page.
*
* @param {number} props.articleId ID of the article to be unpublished.
* @param {string} props.authorUsername Username of the article's author.
* @param {string} props.articleSlug Slug of the article to be unpublished.
*/
export function UnpublishPostModal({
articleId,
authorName,
authorUsername,
articleSlug,
}) {
return (
<div
data-testid="unpublish-post-modal"
class="crayons-modal crayons-modal--small absolute unpublish-post-modal"
>
<div class="crayons-modal__box">
<header class="crayons-modal__box__header unpublish-post-modal-header">
<h2 class="crayons-modal__box__header__title">Unpublish post</h2>
<Button
icon={RemoveIcon}
className="inline-flex"
onClick={toggleUnpublishPostModal}
/>
</header>
<div class="crayons-modal__box__body">
<div class="grid gap-4">
<p>
Once unpublished, this post will become invisible to the public
and only accessible to {authorName}.
</p>
<p>They can still re-publish the post if they are not suspended.</p>
<div>
<Button
destructive
variant="primary"
className="mr-2"
id="confirm-unpublish-post-action"
onClick={(_event) => {
confirmAdminUnpublishPost(
articleId,
authorUsername,
articleSlug,
);
}}
>
Unpublish post
</Button>
</div>
</div>
</div>
</div>
<div
role="presentation"
class="crayons-modal__overlay"
onClick={toggleUnpublishPostModal}
onKeyUp={toggleUnpublishPostModal}
/>
</div>
);
}
UnpublishPostModal.displayName = 'UnpublishPostModal';
UnpublishPostModal.propTypes = {
articleId: PropTypes.number.isRequired,
authorUsername: PropTypes.string.isRequired,
articleSlug: PropTypes.string.isRequired,
};

View file

@ -402,6 +402,7 @@ class User < ApplicationRecord
:comment_suspended?,
:creator?,
:has_trusted_role?,
:moderator?,
:podcast_admin_for?,
:restricted_liquid_tag_for?,
:single_resource_admin_for?,

View file

@ -203,7 +203,7 @@ class ApplicationPolicy
delegate :support_admin?, to: :user
delegate :super_admin?, :any_admin?, :suspended?, to: :user, prefix: true
delegate :moderator?, :super_admin?, :any_admin?, :suspended?, to: :user, prefix: true
alias minimal_admin? user_any_admin?
deprecate minimal_admin?: "Deprecating #{self}#minimal_admin?, use #{self}#user_any_admin?"

View file

@ -144,9 +144,13 @@ class ArticlePolicy < ApplicationPolicy
user_author? || user_super_admin?
end
def admin_unpublish?
# this method performs the same checks that determine
# if the record can be featured
def revoke_publication?
require_user!
user_any_admin?
return false unless @record.published?
user_any_admin? || user_moderator?
end
def destroy?
@ -170,7 +174,13 @@ class ArticlePolicy < ApplicationPolicy
user.trusted?
end
alias admin_featured_toggle? admin_unpublish?
alias admin_featured_toggle? revoke_publication?
alias toggle_featured_status? revoke_publication?
# Due to the associated controller method "admin_unpublish", we
# alias "admin_ubpublish" to the "revoke_publication" method.
alias admin_unpublish? revoke_publication?
alias new? create?

View file

@ -92,6 +92,10 @@ module Authorizer
has_role?(:trusted)
end
def moderator?
has_role?(:moderator)
end
def podcast_admin_for?(podcast)
has_role?(:podcast_admin, podcast)
end

View file

@ -81,12 +81,15 @@
<article class="crayons-card crayons-article mb-4"
id="article-show-container"
data-article-id="<%= @article.id %>"
data-article-slug="<%= @article.slug %>"
data-author-id="<%= @article.user_id %>"
data-author-name="<%= @article.cached_user_name %>"
data-author-username="<%= @article.username %>"
data-co-author-ids="<%= @article.co_author_ids.join(",") %>"
data-path="<%= @article.path %>"
data-published="<%= @article.published? %>"
data-pin-path="<%= stories_feed_pinned_article_path %>"
data-pinned-article-id="<%= @pinned_article_id %>"
data-published="<%= @article.published? %>"
<%= @article.pinned? ? "data-pinned" : " " %>>
<header class="crayons-article__header" id="main-title">
<% if @article.video.present? %>
@ -216,6 +219,7 @@
<div class="mod-actions-menu print-hidden"></div>
<div data-testid="flag-user-modal-container" class="flag-user-modal-container hidden"></div>
<div data-testid="unpublish-post-modal-container" class="unpublish-post-modal-container hidden"></div>
<div class="fullscreen-code js-fullscreen-code"></div>
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>

View file

@ -48,8 +48,14 @@
<span></span>
<%= crayons_icon_tag(:checkmark, class: "vomit-checkmark", title: t("views.moderations.actions.checkmark")) %>
</button>
<% if current_user.any_admin? && @moderatable.published %>
<% @recent_featured_count = Article.where(featured: true).where("published_at > ?", 1.day.ago).size %>
<% if policy(@moderatable).revoke_publication? %>
<button
class="c-btn c-btn--primary c-btn--destructive w-100 my-4"
id="unpublish-article-btn">
<%= t("views.moderations.actions.unpublish") %>
</button>
<% end %>
<% if policy(@moderatable).toggle_featured_status? %>
<button
class="c-btn c-btn--primary w-100"
id="feature-article-btn"
@ -60,7 +66,8 @@
<%= @moderatable.featured ? t("views.moderations.actions.unfeature") : t("views.moderations.actions.feature") %>
</button>
<div class="additional-subtext-section py-4 pt-2">
<%= t("views.moderations.actions.featured_past_day", count: @recent_featured_count) %>
<% recent_featured_count = Article.where(featured: true).where("published_at > ?", 1.day.ago).size %>
<%= t("views.moderations.actions.featured_past_day", count: recent_featured_count) %>
</div>
<% end %>
<a href="<%= URL.url %>/community-moderation#using-the-quick-reactions-to-moderate-content" target="_blank" rel="noopener">
@ -175,8 +182,9 @@
</a>
</div>
<% if current_user.any_admin? && @moderatable.published %>
<button aria-haspopup="true" aria-expanded="false" aria-controls="admin-action-options" class="other-things-btn admin-action" type="button" data-other-things-type="admin-action" aria-label="<%= t("views.moderations.actions.admin.aria_label") %>">
<%# Hiding this Admin Actions panel until Flag User, Unpublish All Posts and Suspend User are moved into it %>
<% if current_user.any_admin? %>
<button aria-haspopup="true" aria-expanded="false" aria-controls="admin-action-options" class="other-things-btn admin-action hidden" type="button" data-other-things-type="admin-action" aria-label="<%= t("views.moderations.actions.admin.aria_label") %>">
<div class="label-wrapper">
<div class="icon circle centered-icon">
<%= inline_svg_tag("admin.svg", aria_hidden: true, title: t("views.moderations.actions.admin.icon")) %>
@ -190,19 +198,6 @@
<%= inline_svg_tag("arrow-down-fill.svg", aria: true, title: t("views.moderations.actions.toggle")) %>
</div>
</button>
<div id="admin-action-options" class="admin-action-options dropdown-options hidden">
<div class="article-admin-action">
<button
class="c-btn c-btn--primary c-btn--destructive w-100"
id="unpublish-article-btn"
data-article-id="<%= @moderatable.id %>"
data-article-author="<%= @moderatable.username %>"
data-article-slug="<%= @moderatable.slug %>">
<%= t("views.moderations.actions.unpublish") %>
</button>
</div>
</div>
<% end %>
</div>

View file

@ -26,8 +26,8 @@
</div>
</section>
</div>
<div data-testid="flag-user-modal-container" class="flag-user-modal-container hidden">
</div>
<div data-testid="flag-user-modal-container" class="flag-user-modal-container hidden"></div>
<div data-testid="unpublish-post-modal-container" class="unpublish-post-modal-container hidden"></div>
</main>
<% else %>
<div class="container" style="margin-top: 90px;">

View file

@ -79,6 +79,18 @@ describe('Moderation Tools for Posts', () => {
});
});
});
it('should show Unpublish Post button on a published post for an admin user', () => {
cy.get('@adminUser').then((user) => {
cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => {
cy.findByRole('button', { name: 'Moderation' }).click();
cy.getIframeBody('[title="Moderation panel actions"]').within(() => {
cy.findByRole('button', { name: 'Unpublish Post' }).should('exist');
});
});
});
});
});
context('as moderator user', () => {
@ -111,6 +123,18 @@ describe('Moderation Tools for Posts', () => {
);
});
});
it('should show Unpublish Post button on a published post for a moderator user', () => {
cy.get('@moderatorUser').then((user) => {
cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => {
cy.findByRole('button', { name: 'Moderation' }).click();
cy.getIframeBody('[title="Moderation panel actions"]').within(() => {
cy.findByRole('button', { name: 'Unpublish Post' }).should('exist');
});
});
});
});
});
context('as trusted user', () => {

View file

@ -71,6 +71,10 @@ FactoryBot.define do
after(:build) { |user| user.add_role(:admin) }
end
trait :moderator do
after(:build) { |user| user.add_role(:moderator) }
end
trait :single_resource_admin do
transient do
resource { nil }

View file

@ -15,6 +15,7 @@ RSpec.describe ArticlePolicy do
let(:trusted) { create(:user, :trusted) }
let(:other_users) { create(:user) }
let(:author) { create(:user) }
let(:moderator) { create(:user, :moderator) }
let(:resource) { build(:article, user: author, organization: organization) }
let(:policy) { described_class.new(user, resource) }
@ -153,13 +154,28 @@ RSpec.describe ArticlePolicy do
it_behaves_like "disallowed roles", to: %i[admin org_admin other_users]
end
%i[admin_unpublish? admin_featured_toggle?].each do |method_name|
describe "admin_unpublish?" do
%i[admin_unpublish? admin_featured_toggle? revoke_publication? toggle_featured_status?].each do |method_name|
describe "##{method_name}" do
let(:policy_method) { method_name }
it_behaves_like "it requires an authenticated user"
it_behaves_like "permitted roles", to: %i[super_admin admin]
it_behaves_like "disallowed roles", to: %i[org_admin author other_users]
context "when published article" do
# need "create" (as opposed to "build") for the article to be published
let(:resource) { create(:article, user: author, organization: organization) }
it_behaves_like "it requires an authenticated user"
it_behaves_like "permitted roles", to: %i[super_admin admin moderator]
it_behaves_like "disallowed roles", to: %i[org_admin author other_users]
end
context "when unpublished article" do
let(:resource) do
build(:article, user: author, organization: organization, published: false, published_at: nil)
end
it_behaves_like "it requires an authenticated user"
it_behaves_like "permitted roles", to: %i[]
it_behaves_like "disallowed roles", to: %i[super_admin admin moderator org_admin author other_users]
end
end
end

View file

@ -4,10 +4,11 @@ require "rails_helper"
RSpec.describe Authorizer, type: :policy do
subject(:authorizer) { described_class.for(user: user) }
let(:authorizer_mod_role) { described_class.for(user: mod_user) }
let(:user) { create(:user) }
let(:mod_user) { create(:user, :moderator) }
describe "#any_admin?" do
# This test che
it "queries the user's roles" do
# I want to test `expect(authorizer.admin?)` but our rubocop
# version squaks.
@ -15,6 +16,13 @@ RSpec.describe Authorizer, type: :policy do
end
end
describe "#moderator?" do
it "queries the user's roles" do
expect(authorizer.moderator?).to be_falsey
expect(authorizer_mod_role.moderator?).to be_truthy
end
end
describe "#administrative_access_to?" do
subject(:method_call) { authorizer.administrative_access_to?(resource: resource) }