Admin comments page with flag and quality reaction details. (#19647)

* Created separate comments page

* Optimised code for single comment and multiple comments

* Indifual comments poage added

* Minor header change

* Created show method

* Full implementation with eager loading error

* Temporarily suppress Bullet from Admin::CommentsController

* Minor fixes

* Minor fixes

* Added table

* Added french translations

* Added reaction stats / summary

* Tests added fore empty comments

* Added seed data and few more tests

* Updated test

* Added model tests for comment and article

* Nit test fix

* Applied changes as per PJ's review

---------

Co-authored-by: Joshua Wehner <joshua@forem.com>
This commit is contained in:
Rajat Talesra 2023-07-05 22:05:35 +05:30 committed by GitHub
parent 7ecb72413d
commit b397292a33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 309 additions and 10 deletions

View file

@ -1,26 +1,32 @@
module Admin
class CommentsController < Admin::ApplicationController
around_action :skip_bullet, if: -> { defined?(Bullet) }
layout "admin"
def index
@comments = if params[:state]&.start_with?("toplast-")
Comment
.includes(:user)
.includes(:commentable)
.includes(:user, :commentable, :reactions)
.order(public_reactions_count: :desc)
.where("created_at > ?", params[:state].split("-").last.to_i.days.ago)
.page(params[:page] || 1).per(50)
else
Comment
.includes(:user)
.includes(:commentable)
.includes(:user, :commentable, :reactions)
.order(created_at: :desc)
.page(params[:page] || 1).per(50)
end
@countable_vomits = {}
@comments.each do |comment|
@countable_vomits[comment.id] = calculate_flags_for_single_comment(comment)
end
end
def show
@comment = Comment.includes(:user, :commentable).find(params[:id])
@comment = Comment.includes(:user, :commentable, :reactions).find(params[:id])
@countable_vomits = {}
@countable_vomits[@comment.id] = calculate_flags_for_single_comment(@comment)
end
private
@ -28,5 +34,19 @@ module Admin
def authorize_admin
authorize Comment, :access?, policy_class: InternalPolicy
end
def calculate_flags_for_single_comment(comment)
comment.reactions.privileged_category.count do |reaction|
reaction.category == "vomit" && reaction.status != "invalid"
end
end
def skip_bullet
previous_value = Bullet.enable?
Bullet.enable = false
yield
ensure
Bullet.enable = previous_value
end
end
end

View file

@ -194,6 +194,10 @@ class Comment < ApplicationRecord
ancestry && Comment.exists?(id: ancestry)
end
def privileged_reaction_counts
@privileged_reaction_counts ||= reactions.privileged_category.group(:category).count
end
private_class_method :build_sort_query
private

View file

@ -222,7 +222,7 @@
<% if params[:tab] == "quality_reactions" %>
<% if quality_article_reactions.present? %>
<% quality_article_reactions.each do |quality_reaction| %>
<%= render "admin/articles/quality_action_item", quality_reaction: quality_reaction %>
<%= render "admin/shared/quality_action_item", quality_reaction: quality_reaction %>
<hr id="js__reaction__div__hr__<%= quality_reaction.id %>" class="w-100 hr-no-margins">
<% end %>
<% else %>

View file

@ -14,6 +14,27 @@
<a href="<%= comment.commentable.path %>" class="c-link c-link--branded"><%= comment.commentable.title %></a>
</p>
</div>
<div class="flex items-center gap-1">
<span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.thumb_up") %>">
<%= crayons_icon_tag("twemoji/thumb-up", native: true, width: 16, height: 16) %>
<span class="fs-s fw-medium lh-base"><%= comment.privileged_reaction_counts["thumbsup"] || "0" %></span>
</span>
<span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.thumb_down") %>">
<%= crayons_icon_tag("twemoji/thumb-down", native: true, width: 16, height: 16) %>
<span class="fs-s fw-medium lh-base"><%= comment.privileged_reaction_counts["thumbsdown"] || "0" %></span>
</span>
<span class="crayons-card crayons-card--secondary px-3 py-1 flex gap-2 items-center" title="<%= t("views.moderations.actions.vomit") %>">
<%= crayons_icon_tag("twemoji/suspicious", native: true, width: 16, height: 16) %>
<span class="fs-s fw-medium lh-base"><%= (@countable_vomits&.dig(comment.id) || 0) %></span>
</span>
<span class="crayons-card crayons-card--secondary px-3 py-1 ml-3 flex gap-2 items-center" title="<%= t("views.moderations.actions.score") %>">
<%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %>
<span class="fs-s fw-medium lh-base"><%= comment.score %></span>
</span>
</div>
</header>
<% end %>
<div class="text-styles text-styles--tertiary">
@ -35,4 +56,47 @@
<% end %>
</footer>
</article>
<% if defined?(is_individual_comment) && is_individual_comment %>
<% privileged_comment_reactions = comment.reactions.privileged_category %>
<% vomit_comment_reactions = privileged_comment_reactions.select { |reaction| reaction.category == "vomit" }.reverse %>
<% quality_comment_reactions = (privileged_comment_reactions - vomit_comment_reactions).reverse %>
<article class="js-individual-article crayons-card py-6 flex flex-col mt-4">
<h2 class="crayons-subtitle-2 mx-6"><%= t("views.admin.comments.priviliged_actions.title") %></h2>
<p class="crayons-subtitle-3 fw-normal color-secondary mt-1 mx-6"><%= t("views.admin.comments.priviliged_actions.description") %></p>
<nav class="mt-4 pt-1 pb-2 px-3 member-data-heading" aria-label="Member details">
<ul class="crayons-navigation crayons-navigation--horizontal">
<li><%= link_to "Flags", admin_comment_path(tab: :flags), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if params[:tab] == 'flags' || params[:tab].blank?}", aria: @current_tab == "flags" ? { current: "" } : {} %></li></li>
<li><%= link_to "Quality reactions", admin_comment_path(tab: :quality_reactions), class: "crayons-navigation__item #{'crayons-navigation__item crayons-navigation__item--current' if params[:tab] == 'quality_reactions'}",
aria: @current_tab == "quality_reactions" ? { current: "" } : {} %></li></li>
</ul>
</nav>
<div id="reaction-content" class="flex flex-col gap-3 px-6 mt-6" style="overflow: auto; height: 406px;">
<% if params[:tab].blank? || params[:tab] == "flags" %>
<%= render "admin/shared/flag_reactions_table",
vomit_reactions: vomit_comment_reactions,
text_section: "comments",
empty_text: t("views.admin.comments.priviliged_actions.no_flags") %>
<% end %>
<% if params[:tab] == "quality_reactions" %>
<% if quality_comment_reactions.present? %>
<% quality_comment_reactions.each do |quality_reaction| %>
<%= render "admin/shared/quality_action_item", quality_reaction: quality_reaction %>
<hr id="js__reaction__div__hr__<%= quality_reaction.id %>" class="w-100 hr-no-margins">
<% end %>
<% else %>
<div class="flex flex-col justify-center items-center gap-4 h-100">
<div class="flex p-4 gap-2 radius-default" style="background: #EEF2FF;">
<%= crayons_icon_tag("quality-reactions", native: true, width: 56, height: 56) %>
</div>
<p class="crayons-subtitle-3 fw-normal color-secondary"><%= t("views.admin.comments.priviliged_actions.no_quality_reactions") %></p>
</div>
<% end %>
<% end %>
<div>
</article>
<% end %>
</div>

View file

@ -10,6 +10,14 @@ en:
no_flags: Article has no flags.
no_quality_reactions: Article has no quality reactions by trusted users.
title: Moderator actions
comments:
flags:
score: Score affected by a particular flag
priviliged_actions:
description: All the moderator actions affect the score. The overall score is a combination of moderator actions and public reactions.
no_flags: Comment has no flags.
no_quality_reactions: Comment has no quality reactions by trusted users.
title: Moderator actions
shared:
flags:
actions:

View file

@ -10,6 +10,14 @@ fr:
no_flags: Article has no flags.
no_quality_reactions: Article has no quality reactions by trusted users.
title: Moderator actions
comments:
flags:
score: Score affecté par un drapeau particulier
priviliged_actions:
description: Toutes les actions du modérateur affectent le score. Le score global est une combinaison des actions des modérateurs et des réactions du public.
no_flags: Le commentaire n'a pas de drapeaux.
no_quality_reactions: Le commentaire n'a pas de réactions de qualité par des utilisateurs de confiance.
title: Actions du modérateur
shared:
flags:
actions:

View file

@ -0,0 +1,124 @@
describe('View details link on comments list page in admin area', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/adminUser.json').as('user');
cy.get('@user').then((user) => {
cy.loginUser(user).then(() => {
cy.visit(`/admin/content_manager/comments`);
});
});
});
it('should contain view details link with correct href', () => {
cy.findAllByRole('link', { name: 'View Details' }).each(($link) => {
const href = $link.attr('href');
const regex = /\/(\d+)/; // Regular expression to match any number in the URL
expect(href).to.match(regex);
});
});
});
describe('New comment with empty flags and reactions', () => {
let commentId;
beforeEach(() => {
cy.testSetup();
cy.fixture('users/adminUser.json').as('user');
cy.get('@user').then((user) => {
cy.loginAndVisit(user, '/').then(() => {
cy.createArticle({
title: 'Test Article',
tags: ['beginner', 'ruby', 'go'],
content: `This is a test article's contents.`,
published: true,
}).then((response) => {
cy.createComment({
content: 'This is a test comment.',
commentableId: response.body.id,
commentableType: 'Article',
}).then((commentResponse) => {
commentId = commentResponse.body.id;
cy.visit(`/admin/content_manager/comments/${commentId}`);
});
});
});
});
});
it('should not contain view details link on individual comments page', () => {
cy.findAllByRole('link', { name: 'View Details' }).should('not.exist');
});
it('should update url on flag tab click', () => {
cy.findByRole('link', { name: 'Flags' }).click();
cy.url().should(
'contains',
`/admin/content_manager/comments/${commentId}?tab=flags`,
);
});
it('should update url on quality reactions tab click', () => {
cy.findByRole('link', { name: 'Quality reactions' }).click();
cy.url().should(
'contains',
`/admin/content_manager/comments/${commentId}?tab=quality_reactions`,
);
});
it('should not contain any flags and quality reactions', () => {
cy.findByText('Comment has no flags.').should('exist');
cy.findByRole('link', { name: 'Quality reactions' }).click();
cy.findByText('Comment has no quality reactions by trusted users.').should(
'exist',
);
});
it('should display the correct values for privileged reactions and score', () => {
// Thumbs-up count
cy.get('.flex .crayons-card:nth-child(1) .fs-s').should('contain', '0');
// Thumbs-down count
cy.get('.flex .crayons-card:nth-child(2) .fs-s').should('contain', '0');
// Vomit/flag count
cy.get('.flex .crayons-card:nth-child(3) .fs-s').should('contain', '0');
//Score
cy.get('.flex .crayons-card:nth-child(4) .fs-s').should('contain', '0');
});
});
describe('Comment with flags and reactions', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/adminUser.json').as('user');
cy.get('@user').then((user) => {
cy.loginUser(user).then(() => {
cy.visit(`/admin/content_manager/comments`);
cy.contains('Contains various privileged reactions.')
.parents('.crayons-card')
.within(() => {
cy.contains('View Details').click();
});
});
});
});
it('should display the correct values for privileged reactions and score', () => {
// Thumbs-up count
cy.get('.flex .crayons-card:nth-child(1) .fs-s').should('contain', '0');
// Thumbs-down count
cy.get('.flex .crayons-card:nth-child(2) .fs-s').should('contain', '1');
// Vomit/flag count
cy.get('.flex .crayons-card:nth-child(3) .fs-s').should('contain', '1');
//Score
cy.get('.flex .crayons-card:nth-child(4) .fs-s').should('contain', '0');
});
});

View file

@ -1276,6 +1276,41 @@ RSpec.describe Article do
end
end
describe "#privileged_reaction_counts" do
it "contains correct vomit count" do
user = create(:user, :trusted)
create(:reaction, reactable: article, category: "vomit", user: user)
counts = article.privileged_reaction_counts
expect(counts["vomit"]).to eq(1)
expect(counts["thumbsup"]).to be_nil
expect(counts["thumbsdown"]).to be_nil
end
it "contains correct thumbsup count" do
user = create(:user, :trusted)
create(:reaction, reactable: article, category: "thumbsup", user: user)
counts = article.privileged_reaction_counts
expect(counts["vomit"]).to be_nil
expect(counts["thumbsup"]).to eq(1)
expect(counts["thumbsdown"]).to be_nil
end
it "contains correct thumbsdown count" do
user = create(:user, :trusted)
create(:reaction, reactable: article, category: "thumbsdown", user: user)
counts = article.privileged_reaction_counts
expect(counts["vomit"]).to be_nil
expect(counts["thumbsup"]).to be_nil
expect(counts["thumbsdown"]).to eq(1)
end
it "returns an empty hash if there are no privileged reactions" do
counts = article.privileged_reaction_counts
expect(counts).to be_empty
end
end
describe "#followers" do
it "returns an array of users who follow the article's author" do
following_user = create(:user)

View file

@ -159,7 +159,7 @@ RSpec.describe Comment do
it "not double wrap an already-linked mention" do
comment.body_markdown = "Hello <a href='/#{user.username}'>@#{user.username}</a>, you are cool."
comment.validate!
expect(comment.processed_html.scan(/href/).count).to eq(1)
expect(comment.processed_html.scan("href").count).to eq(1)
end
it "does not wrap email mention with username" do
@ -188,7 +188,6 @@ RSpec.describe Comment do
expect(comment.processed_html.include?("Hello <a")).to be(true)
end
# rubocop:disable RSpec/ExampleLength
it "shortens long urls without removing formatting", :aggregate_failures do
long_url = "https://longurl.com/#{'x' * 100}?#{'y' * 100}"
comment.body_markdown = "Hello #{long_url}"
@ -395,6 +394,7 @@ RSpec.describe Comment do
expect(comments).to eq([new_comment.id, comment.id, other_comment.id, old_comment.id])
end
# rubocop:disable RSpec/ExampleLength
it "returns comments in the right order when order is top" do
comment.update_column(:score, 5)
highest_rated_comment = comment
@ -409,6 +409,7 @@ RSpec.describe Comment do
comments = comments.map { |key, _| key.id }
expect(comments).to eq([highest_rated_comment.id, mid_high_rated_comment.id, mid_low_rated_comment.id, lowest_rated_comment.id]) # rubocop:disable Layout/LineLength
end
# rubocop:enable RSpec/ExampleLength
end
end
@ -427,6 +428,7 @@ RSpec.describe Comment do
end.to change(Comments::CalculateScoreWorker.jobs, :size).by(1)
end
# rubocop:disable RSpec/ExampleLength
it "enqueues a worker to send email" do
comment.save!
child_comment_user = create(:user)
@ -436,6 +438,7 @@ RSpec.describe Comment do
child_comment.save!
end.to change(Comments::SendEmailNotificationWorker.jobs, :size).by(1)
end
# rubocop:enable RSpec/ExampleLength
it "enqueues a worker to bust comment cache" do
expect do
@ -491,6 +494,35 @@ RSpec.describe Comment do
end
end
describe "#privileged_reaction_counts" do
it "contains correct vomit count" do
user = create(:user, :trusted)
create(:reaction, reactable: comment, category: "vomit", user: user)
counts = comment.privileged_reaction_counts
expect(counts["vomit"]).to eq(1)
end
it "contains correct thumbsup count" do
user = create(:user, :trusted)
create(:reaction, reactable: comment, category: "thumbsup", user: user)
counts = comment.privileged_reaction_counts
expect(counts["thumbsup"]).to eq(1)
end
it "contains correct thumbsdown count" do
user = create(:user, :trusted)
create(:reaction, reactable: comment, category: "thumbsdown", user: user)
counts = comment.privileged_reaction_counts
expect(counts["thumbsdown"]).to eq(1)
end
it "returns an empty hash if there are no privileged reactions" do
counts = comment.privileged_reaction_counts
expect(counts).to be_empty
end
end
context "when callbacks are triggered before save" do
context "when the post is present" do
it "generates character count before saving" do
@ -540,6 +572,7 @@ RSpec.describe Comment do
expect(comment.notifications).to be_empty
end
# rubocop:disable RSpec/ExampleLength
it "updates the notifications of the descendants with [deleted]" do
comment = create(:comment, commentable: article)
child_comment = create(:comment, parent: comment, commentable: article, user: user)
@ -550,6 +583,7 @@ RSpec.describe Comment do
notification = child_comment.notifications.first
expect(notification.json_data["comment"]["ancestors"][0]["title"]).to eq("[deleted]")
end
# rubocop:enable RSpec/ExampleLength
end
context "when callbacks are triggered after destroy" do

View file

@ -827,13 +827,15 @@ seeder.create_if_doesnt_exist(Article, "title", "Series test article") do
)
comment_attributes = {
body_markdown: Faker::Hipster.paragraph(sentence_count: 1),
body_markdown: "Contains various privileged reactions.",
user_id: questionable_user.id,
commentable_id: article.id,
commentable_type: "Article"
}
Comment.create!(comment_attributes)
comment = Comment.create!(comment_attributes)
admin_user.reactions.create!(category: :vomit, reactable: comment, status: :confirmed)
admin_user.reactions.create!(category: :thumbsdown, reactable: comment)
end
##############################################################################