+ <% if params[:tab].blank? || params[:tab] == "flags" %>
+ <% if vomit_article_reactions.present? %>
+ <% vomit_article_reactions.each do |vomit_reaction| %>
+ <%= render "admin/articles/flag_action_item", vomit_reaction: vomit_reaction %>
+
+ <% end %>
+ <% else %>
+
+
+ <%= crayons_icon_tag("flags", native: true, width: 56, height: 56) %>
+
+
<%= t("views.moderations.priviliged_actions.no_flags") %>
+
+ <% end %>
+ <% end %>
+
+ <% 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 %>
+
+ <% end %>
+ <% else %>
+
+
+ <%= crayons_icon_tag("quality-reactions", native: true, width: 56, height: 56) %>
+
+
<%= t("views.moderations.priviliged_actions.no_quality_reactions") %>
+
+ <% end %>
+ <% end %>
- Save
-
- <% end %>
-
+
+
diff --git a/app/views/admin/articles/_priviledged_reaction_actions_dropdown.html.erb b/app/views/admin/articles/_priviledged_reaction_actions_dropdown.html.erb
new file mode 100644
index 000000000..bc7120c55
--- /dev/null
+++ b/app/views/admin/articles/_priviledged_reaction_actions_dropdown.html.erb
@@ -0,0 +1,42 @@
+
<%= crayons_icon_tag("pin.svg") %> Pinned Article
- <%= render partial: "individual_article", locals: { article: @pinned_article } %>
+ <%= render partial: "article_item", locals: { article: @pinned_article } %>
<% end %>
<% if @featured_articles.present? %>
Manually Featured Articles
- <%= render partial: "individual_article", collection: @featured_articles, as: :article %>
+ <%= render partial: "article_item", collection: @featured_articles, as: :article %>
<% end %>
- <%= render partial: "individual_article", collection: @articles, as: :article %>
+ <%= render partial: "article_item", collection: @articles, as: :article %>
<%= paginate @articles %>
<%= render partial: "pinned_article_modal" %>
diff --git a/config/environments/development.rb b/config/environments/development.rb
index a3177e59a..a1532e039 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -149,6 +149,8 @@ Rails.application.configure do
Bullet.add_safelist(type: :n_plus_one_query, class_name: "User", association: :profile)
Bullet.add_safelist(type: :unused_eager_loading, class_name: "FeedbackMessage", association: :reporter)
+ Bullet.add_safelist(type: :unused_eager_loading, class_name: "Article", association: :reactions)
+ Bullet.add_safelist(type: :unused_eager_loading, class_name: "Reaction", association: :user)
# Check if there are any data update scripts to run during startup
if %w[Console Server DBConsole].any? { |const| Rails.const_defined?(const) } && DataUpdateScript.scripts_to_run?
diff --git a/config/locales/views/moderations/en.yml b/config/locales/views/moderations/en.yml
index 1538d8a77..37aa8ac7a 100644
--- a/config/locales/views/moderations/en.yml
+++ b/config/locales/views/moderations/en.yml
@@ -75,6 +75,7 @@ en:
Use
Flag to Admins for code of conduct violations (harassment, being a jerk, spam, etc.).
other: Other things you can do
add_reaction: Add a reaction
+ score: Score
suspend:
suspend_title: Suspend %{username}
suspend_user: Suspend %{username}
@@ -117,6 +118,7 @@ en:
unpublish_post:
modal_text1: Once unpublished, this post will become invisible to the public and only accessible to %{username}.
modal_text2: They can still re-publish the post if they are not suspended.
+ vomit: Valid and open flags
vote_down: Low Quality
flag_to_admins: Flag to Admins
vote_up: High Quality
@@ -124,6 +126,27 @@ en:
one: '%{count} post from the past day is featured.'
other: '%{count} posts from the past day are featured.'
all: All topics
+ article:
+ approved: Approved
+ author_id: Author ID
+ comments: comments
+ contains_video: Contains video
+ co_author_ids: Co-Author IDs
+ edit: Edit
+ featured: Featured
+ likes: likes
+ notes:
+ user_notes: user notes
+ view_all: View all
+ pinned: Pinned
+ pin_post: Pin post
+ published_at: Published at
+ save: Save
+ unpin_post: Unpin post
+ unplublished: Unplublished
+ user: User
+ user_warned: User warned
+ view_details: View details
aside:
all: All topics
code_of_conduct: Code of Conduct
@@ -140,6 +163,22 @@ en:
trusted: Trusted User Guide
author: Author
date: Date
+ flags:
+ actions:
+ mark_as_invalid: Mark as Invalid
+ mark_as_valid: Mark as Valid
+ invalid:
+ title: Flag marked as invalid by admin
+ value: Invalid
+ open:
+ title: Open Flag
+ value: Open
+ unidentified:
+ title: Unidentified
+ value: Unidentified
+ valid:
+ title: Flag marked as valid by admin
+ value: Valid
notice:
subtitle: '%{community} Mods'
desc1_html: We periodically award some %{community} members with heightened privileges to help moderate the community.
@@ -150,3 +189,8 @@ en:
tag: Tag Moderation Guide
trusted: Trusted User Guide
post: Post
+ priviliged_actions:
+ description: All the moderator actions affect the score. The overall score is a combination of moderator actions and public reactions.
+ no_flags: Article has no flags.
+ no_quality_reactions: Article has no quality reactions by trusted users.
+ title: Moderator actions
diff --git a/config/locales/views/moderations/fr.yml b/config/locales/views/moderations/fr.yml
index a0aeff371..bc561d618 100644
--- a/config/locales/views/moderations/fr.yml
+++ b/config/locales/views/moderations/fr.yml
@@ -74,6 +74,7 @@ fr:
Utilisez
Signaler aux admins pour les violations du code de conduite (harcèlement, être un abruti, spam, etc.).
other: Autres choses que vous pouvez faire
+ score: Score
suspend:
suspend_title: Suspendre %{username}
suspend_user: Suspendre %{username}
@@ -116,6 +117,7 @@ fr:
unpublish_post:
modal_text1: Une fois dépublié, cet article sera invisible pour le public et accessible uniquement à %{username}.
modal_text2: Ils peuvent toujours republier l'article tant qu'ils ne sont pas suspendus.
+ vomit: Drapeaux valides et ouverts
vote_down: Faible qualité
flag_to_admins: Signaler aux admins
vote_up: Haute qualité
@@ -123,6 +125,27 @@ fr:
one: '%{count} des articles de la journée précédente sont mis en avant.'
other: '%{count} des articles de la journée précédente sont mis en avant.'
all: Tous les sujets
+ article:
+ approved: Approuvée
+ author_id: Identifiant de l'auteur
+ comments: commentaires
+ contains_video: Contient une vidéo
+ co_author_ids: ID de co-auteur
+ edit: Modifier
+ featured: Mis en exergue
+ likes: aime
+ notes:
+ user_notes: notes d'utilisateur
+ view_all: Voir tout
+ pinned: Épinglé
+ pin_post: Épingler la publication
+ published_at: Publié à
+ save: Sauvegarder
+ unpin_post: Détacher la publication
+ unplublished: Inédit
+ user: Utilisateur
+ user_warned: Utilisateur averti
+ view_details: Voir les détails
aside:
all: Tous les sujets
code_of_conduct: Code de conduite
@@ -139,6 +162,22 @@ fr:
trusted: Guide de l'utilisateur de confiance
author: Auteur
date: Date
+ flags:
+ actions:
+ mark_as_invalid: Marquer comme non valide
+ mark_as_valid: Marquer comme valide
+ invalid:
+ title: Drapeau marqué comme invalide par l'administrateur
+ value: Invalide
+ open:
+ title: Drapeau ouvert
+ value: Ouvrir
+ unidentified:
+ title: Non identifié
+ value: Non identifié
+ valid:
+ title: Drapeau marqué comme valide par l'administrateur
+ value: Valide
notice:
subtitle: '%{community} Mods'
desc1_html: Nous accordons périodiquement à certains membres de la %{community} des privilèges accrus afin de contribuer à la modération de la communauté.
@@ -149,3 +188,8 @@ fr:
tag: Guide de modération des étiquettes
trusted: Guide de l'utilisateur de confiance
post: Article
+ 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: L'article n'a pas de drapeaux.
+ no_quality_reactions: L'article n'a pas de réactions de qualité par des utilisateurs de confiance
+ title: Actions privilégiées
diff --git a/cypress/e2e/seededFlows/adminFlows/articles/privilegedReactions.spec.js b/cypress/e2e/seededFlows/adminFlows/articles/privilegedReactions.spec.js
new file mode 100644
index 000000000..5524d1055
--- /dev/null
+++ b/cypress/e2e/seededFlows/adminFlows/articles/privilegedReactions.spec.js
@@ -0,0 +1,268 @@
+describe('New article has empty flags, quality reactions and score', () => {
+ let articleId;
+ beforeEach(() => {
+ cy.testSetup();
+ cy.fixture('users/adminUser.json').as('user');
+
+ cy.get('@user').then((user) => {
+ cy.loginUser(user).then(() => {
+ cy.createArticle({
+ title: 'Test article',
+ tags: ['beginner', 'ruby', 'go'],
+ content: `This is another test article's contents.`,
+ published: true,
+ }).then((response) => {
+ articleId = response.body.id;
+ cy.visit(`/admin/content_manager/articles/${articleId}`);
+ });
+ });
+ });
+ });
+
+ it('should update url on flag tab click', () => {
+ cy.findByRole('link', { name: 'Flags' }).click();
+ cy.url().should(
+ 'contains',
+ `/admin/content_manager/articles/${articleId}?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/articles/${articleId}?tab=quality_reactions`,
+ );
+ });
+
+ it('should not contain any flags and quality reactions', () => {
+ cy.findByText('Article has no flags.').should('exist');
+
+ cy.findByRole('link', { name: 'Quality reactions' }).click();
+ cy.findByText('Article 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('Article flagged by an admin user', () => {
+ beforeEach(() => {
+ cy.testSetup();
+ cy.fixture('users/adminUser.json').as('user');
+
+ cy.get('@user').then((user) => {
+ cy.loginUser(user).then(() => {
+ cy.visit('/admin/content_manager/articles');
+
+ cy.contains('.crayons-subtitle-1 a', 'Punctuation user article')
+ .parents('.js-individual-article')
+ .find('a[href*="/admin/content_manager/articles/"]')
+ .click();
+ });
+ });
+ });
+
+ it('should display the correct default data for flag reaction', () => {
+ // Vomit/flag count
+ cy.get('.flex .crayons-card:nth-child(3) .fs-s').should('contain', '1');
+
+ // Reaction username
+ cy.get('.flex .crayons-subtitle-3').should('contain', 'Admin McAdmin');
+
+ // Shows "Valid" status
+ cy.get('.flex .c-indicator').should('be.visible').as('flagStatus');
+ cy.get('@flagStatus')
+ .should('have.class', 'c-indicator--danger')
+ .and('contain', 'Valid');
+
+ // Reeaction score
+ cy.get('.flex .crayons-card:nth-child(2) .fs-s').should('contain', '-100');
+ });
+
+ it('should open the dropdown on button click and close the dropdown on same button click', () => {
+ // Opens the dropdown
+ cy.get('.c-btn--icon-alone').click();
+ cy.get('.crayons-dropdown').should('be.visible');
+
+ // Closes the dropdown on same button click
+ cy.get('.c-btn--icon-alone').click();
+ cy.get('.crayons-dropdown').should('not.visible');
+ });
+
+ it('should update status to Invalid on click of "Mark as Invalid"', () => {
+ cy.intercept('PATCH', '/admin/reactions/**', (req) => {
+ req.reply((res) => {
+ const { id } = req.body;
+ const response = {
+ statusCode: 200,
+ body: {
+ outcome: 'Success',
+ },
+ };
+
+ if (req.body.removeElement === true) {
+ cy.get(`#${id}`).then(($element) => {
+ $element.remove();
+ });
+ cy.get(`#js__reaction__div__hr__${id}`).then(($element) => {
+ $element.remove();
+ });
+ }
+
+ res.send(response);
+ });
+ }).as('request');
+
+ cy.get('.c-btn--icon-alone:first').click();
+
+ cy.get('.crayons-dropdown')
+ .find('ul.list-none')
+ .should('contain', 'Mark as Invalid')
+ .click();
+
+ cy.wait('@request');
+
+ cy.get('.flex .c-indicator').should('be.visible').as('flagStatus');
+ cy.get('@flagStatus').should('contain', 'Invalid');
+ });
+});
+
+describe('Article flagged by a trusted user', () => {
+ beforeEach(() => {
+ cy.testSetup();
+ cy.fixture('users/adminUser.json').as('user');
+
+ cy.get('@user').then((user) => {
+ cy.loginUser(user).then(() => {
+ cy.visit('/admin/content_manager/articles');
+
+ cy.contains('.crayons-subtitle-1 a', 'Notification article')
+ .parents('.js-individual-article')
+ .find('a[href*="/admin/content_manager/articles/"]')
+ .click();
+ });
+ });
+ });
+
+ it('should display the open flag status and correct score', () => {
+ // Open flag status
+ cy.get('.flex .c-indicator').should('be.visible').as('flagStatus');
+ cy.get('@flagStatus')
+ .should('have.class', 'c-indicator--warning')
+ .and('contain', 'Open');
+
+ // Score
+ cy.get('.flex .crayons-card:nth-child(2) .fs-s').should('contain', '-50');
+ });
+
+ it('should display both "Mark as Valid" and "Mark as Invalid" options in the dropdown', () => {
+ cy.get('.c-btn--icon-alone').click();
+
+ cy.get('.crayons-dropdown')
+ .find('ul.list-none')
+ .should('contain', 'Mark as Valid')
+ .and('contain', 'Mark as Invalid');
+ });
+
+ it('should close dropdown on click of "Mark as Valid"', () => {
+ cy.get('.c-btn--icon-alone:first').click();
+
+ cy.get('.crayons-dropdown')
+ .find('ul.list-none')
+ .should('contain', 'Mark as Valid')
+ .click();
+
+ cy.get('.crayons-dropdown').should('not.be.visible');
+ });
+
+ it('should update status to Invalid on click of "Mark as Invalid"', () => {
+ cy.intercept('PATCH', '/admin/reactions/**', (req) => {
+ req.reply((res) => {
+ const { id } = req.body;
+ const response = {
+ statusCode: 200,
+ body: {
+ outcome: 'Success',
+ },
+ };
+
+ if (req.body.removeElement === true) {
+ cy.get(`#${id}`).then(($element) => {
+ $element.remove();
+ });
+ cy.get(`#js__reaction__div__hr__${id}`).then(($element) => {
+ $element.remove();
+ });
+ }
+
+ res.send(response);
+ });
+ }).as('request');
+
+ cy.get('.c-btn--icon-alone:first').click();
+
+ cy.get('.crayons-dropdown')
+ .find('ul.list-none')
+ .should('contain', 'Mark as Invalid')
+ .click();
+
+ cy.wait('@request');
+
+ cy.get('.flex .c-indicator').should('be.visible').as('flagStatus');
+ cy.get('@flagStatus')
+ .should('have.class', 'c-indicator--relaxed')
+ .and('contain', 'Invalid');
+ });
+
+ it('should update status to Valid on click of "Mark as Valid"', () => {
+ cy.intercept('PATCH', '/admin/reactions/**', (req) => {
+ req.reply((res) => {
+ const { id } = req.body;
+ const response = {
+ statusCode: 200,
+ body: {
+ outcome: 'Success',
+ },
+ };
+
+ if (req.body.removeElement === true) {
+ cy.get(`#${id}`).then(($element) => {
+ $element.remove();
+ });
+ cy.get(`#js__reaction__div__hr__${id}`).then(($element) => {
+ $element.remove();
+ });
+ }
+
+ res.send(response);
+ });
+ }).as('request');
+
+ cy.get('.c-btn--icon-alone:first').click();
+
+ cy.get('.crayons-dropdown')
+ .find('ul.list-none')
+ .findByTestId('mark-as-valid')
+ .should('contain', 'Mark as Valid')
+ .click();
+
+ cy.wait('@request');
+
+ cy.get('.flex .c-indicator').should('be.visible').as('flagStatus');
+ cy.get('@flagStatus').should('contain', 'Valid');
+ });
+});
diff --git a/cypress/e2e/seededFlows/adminFlows/articles/viewDetails.spec.js b/cypress/e2e/seededFlows/adminFlows/articles/viewDetails.spec.js
new file mode 100644
index 000000000..09b09abb8
--- /dev/null
+++ b/cypress/e2e/seededFlows/adminFlows/articles/viewDetails.spec.js
@@ -0,0 +1,40 @@
+describe('View details link on article list page admin area', () => {
+ 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(() => {
+ cy.visit('/admin/content_manager/articles');
+ });
+ });
+ });
+ });
+
+ it('should contain view details button with correct link', () => {
+ 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);
+ });
+ });
+
+ it('should not details view details button on individual article page', () => {
+ cy.createArticle({
+ title: 'A new article',
+ tags: ['beginner', 'ruby', 'go'],
+ content: `This is another test article's contents.`,
+ published: true,
+ }).then((response) => {
+ cy.visit(`/admin/content_manager/articles/${response.body.id}`);
+ });
+
+ cy.findAllByRole('link', { name: 'View details' }).should('not.exist');
+ });
+});
diff --git a/spec/support/seeds/seeds_e2e.rb b/spec/support/seeds/seeds_e2e.rb
index 22239fd1e..2d6070a9c 100644
--- a/spec/support/seeds/seeds_e2e.rb
+++ b/spec/support/seeds/seeds_e2e.rb
@@ -128,18 +128,19 @@ seeder.create_if_doesnt_exist(User, "email", "punctuated-name-user@forem.local")
#{Faker::Markdown.random}
#{Faker::Hipster.paragraph(sentence_count: 2)}
MARKDOWN
- Article.create!(
+ article = Article.create!(
body_markdown: markdown,
featured: true,
show_comments: true,
user_id: user.id,
slug: "apostrophe-user-slug",
)
+ seeder.create_if_none(Reaction) do
+ admin_user.reactions.create!(category: :vomit, reactable: article, status: :confirmed)
+ end
end
end
-##############################################################################
-
seeder.create_if_doesnt_exist(User, "email", "user-with-many-orgs@forem.local") do
User.create!(
name: "Many orgs user",
@@ -440,6 +441,8 @@ seeder.create_if_doesnt_exist(User, "email", "notifications-user@forem.local") d
commentable_type: "Article"
}
+ trusted_user.reactions.create!(category: :vomit, reactable: article)
+
parent_comment = Comment.create!(parent_comment_attributes)
Notification.send_new_comment_notifications_without_delay(parent_comment)