diff --git a/app/assets/images/flags.svg b/app/assets/images/flags.svg new file mode 100644 index 000000000..cf285cf00 --- /dev/null +++ b/app/assets/images/flags.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/quality-reactions.svg b/app/assets/images/quality-reactions.svg new file mode 100644 index 000000000..c00888cc9 --- /dev/null +++ b/app/assets/images/quality-reactions.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss index 6fab2b382..732f178e7 100644 --- a/app/assets/stylesheets/admin.scss +++ b/app/assets/stylesheets/admin.scss @@ -15,7 +15,13 @@ hr { margin-top: 1rem; margin-bottom: 1rem; border: 0; - border-top: 1px solid rgba(0,0,0,.1); + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.hr-no-margins { + margin-top: 0px; + margin-bottom: 0px; + border-top: 1px solid rgba(var(--grey-100), 1); } a:hover { @@ -133,8 +139,8 @@ which may be more easily removed once we no longer wish admin layouts to have a justify-content: space-between; padding: 0.75rem 1.25rem; margin-bottom: 0; - background-color: rgba(0,0,0,.03); - border-bottom: 1px solid rgba(0,0,0,.125); + background-color: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.125); } .card-header__actions { diff --git a/app/controllers/admin/articles_controller.rb b/app/controllers/admin/articles_controller.rb index aefa3bb67..2aaf9af0a 100644 --- a/app/controllers/admin/articles_controller.rb +++ b/app/controllers/admin/articles_controller.rb @@ -33,7 +33,7 @@ module Admin end def show - @article = Article.find(params[:id]) + @article = Article.includes(reactions: :user).find(params[:id]) end def update @@ -59,7 +59,7 @@ module Admin redirect_to admin_article_path(article.id) end format.js do - render partial: "admin/articles/individual_article", locals: { article: article }, content_type: "text/html" + render partial: "admin/articles/article_item", locals: { article: article }, content_type: "text/html" end end end @@ -75,7 +75,7 @@ module Admin redirect_to admin_article_path(article.id) end format.js do - render partial: "admin/articles/individual_article", locals: { article: article }, content_type: "text/html" + render partial: "admin/articles/article_item", locals: { article: article }, content_type: "text/html" end end end diff --git a/app/javascript/admin/controllers/reaction_controller.js b/app/javascript/admin/controllers/reaction_controller.js index ccd2ecc72..9d3ffdbb9 100644 --- a/app/javascript/admin/controllers/reaction_controller.js +++ b/app/javascript/admin/controllers/reaction_controller.js @@ -7,7 +7,7 @@ export default class ReactionController extends Controller { url: String, }; - updateReaction(status) { + updateReaction(status, removeElement = true) { const id = this.idValue; fetch(this.urlValue, { @@ -15,7 +15,7 @@ export default class ReactionController extends Controller { headers: { Accept: 'application/json', 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']") - .content, + ?.content, 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -28,8 +28,16 @@ export default class ReactionController extends Controller { .json() .then((json) => { if (json.outcome === 'Success') { - this.element.remove(); - document.getElementById(`js__reaction__div__hr__${id}`).remove(); + if (removeElement === true) { + this.element.remove(); + document.getElementById(`js__reaction__div__hr__${id}`).remove(); + } else { + // TODO (#19531): Code Optimisation- avoid reloading entire page for this minor item change. + // Once the status of item gets updated in admin/content_manager/articles/, we + // reload the entire page here. Ideally we should only re-render the item which was updated + // but given the case that this feature is used by internal-team, for now its fine. + location.reload(); + } } else { window.alert(json.error); } @@ -40,12 +48,14 @@ export default class ReactionController extends Controller { ); } - updateReactionInvalid() { - this.updateReaction(this.invalidStatus); + updateReactionInvalid(event) { + const { removeElement } = event.target.dataset; + this.updateReaction(this.invalidStatus, removeElement); } - updateReactionConfirmed() { - this.updateReaction(this.confirmedStatus); + updateReactionConfirmed(event) { + const { removeElement } = event.target.dataset; + this.updateReaction(this.confirmedStatus, removeElement); } reactableUserCheck() { diff --git a/app/javascript/packs/admin/articles/vomitReactionItem.js b/app/javascript/packs/admin/articles/vomitReactionItem.js new file mode 100644 index 000000000..101723e63 --- /dev/null +++ b/app/javascript/packs/admin/articles/vomitReactionItem.js @@ -0,0 +1,70 @@ +import { openDropdown, closeDropdown } from '@utilities/dropdownUtils'; + +// We present up to 50 users in the UI at once, and for performance reasons we don't want to add individual click listeners to each dropdown menu or inner menu item +// Instead we listen for click events anywhere in the table, and identify required actions based on data attributes of the target +document + .getElementById('reaction-content') + ?.addEventListener('click', ({ target }) => { + const { + dataset: { markValid, markInvalid, toggleDropdown }, + } = target; + + if (markValid) { + closeCurrentlyOpenDropdown(); + return; + } + + if (markInvalid) { + closeCurrentlyOpenDropdown(); + return; + } + + if (toggleDropdown) { + handleDropdownToggle({ + triggerElementId: target.getAttribute('id'), + dropdownContentId: toggleDropdown, + }); + return; + } + }); + +// We keep track of the currently opened dropdown to make sure we only ever have one open at a time +let currentlyOpenDropdownId; + +const handleDropdownToggle = ({ triggerElementId, dropdownContentId }) => { + const triggerButton = document.getElementById(triggerElementId); + + const isCurrentlyOpen = + triggerButton.getAttribute('aria-expanded') === 'true'; + + if (isCurrentlyOpen) { + closeDropdown({ triggerElementId, dropdownContentId }); + triggerButton.focus(); + currentlyOpenDropdownId = null; + } else { + closeCurrentlyOpenDropdown(); + openDropdown({ triggerElementId, dropdownContentId }); + currentlyOpenDropdownId = dropdownContentId; + } +}; + +/** + * Make sure any currently opened dropdown is closed + */ +const closeCurrentlyOpenDropdown = (focusTrigger = false) => { + if (!currentlyOpenDropdownId) { + return; + } + const triggerButton = document.querySelector( + `[aria-controls='${currentlyOpenDropdownId}']`, + ); + + closeDropdown({ + dropdownContentId: currentlyOpenDropdownId, + triggerElementId: triggerButton?.getAttribute('id'), + }); + + if (focusTrigger) { + triggerButton.focus(); + } +}; diff --git a/app/views/admin/articles/_article_item.html.erb b/app/views/admin/articles/_article_item.html.erb new file mode 100644 index 000000000..da38ddede --- /dev/null +++ b/app/views/admin/articles/_article_item.html.erb @@ -0,0 +1,203 @@ +<% decorated_article = article.decorate %> + +
+ +
+ <% if decorated_article.pinned? || article.featured || article.approved || (!article.published? && article.published_from_feed?) || article.video || article.user&.warned? %> +
+ <% if !article.published? && article.published_from_feed? %> + <%= t("views.moderations.article.unplublished") %> + <% end %> + <% if decorated_article.pinned? %> + <%= t("views.moderations.article.pinned") %> + <% end %> + <% if article.featured %> + <%= t("views.moderations.article.featured") %> + <% end %> + <% if article.approved %> + <%= t("views.moderations.article.approved") %> + <% end %> + <% if article.video %> + <%= t("views.moderations.article.contains_video") %> + <% end %> + <% cache "admin-user-info-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> + <% if article.user&.warned? %> + <%= t("views.moderations.article.user_warned") %> + <% end %> + <% end %> +
+ <% end %> + + <% if article.main_image.present? %> + + + + <% end %> + + <% privileged_article_reactions = article.reactions.privileged_category.select { |reaction| reaction.reactable_type == "Article" } %> + <% vomit_article_reactions = privileged_article_reactions.select { |reaction| reaction.category == "vomit" } %> + <% countable_vomits = 0 %> + <% if vomit_article_reactions.present? %> + <% vomit_article_reactions.each do |vomit_reaction| %> + <% if vomit_reaction.status != 'invalid' %> + <% countable_vomits += 1 %> + <% end %> + <% end %> + <% end %> + +
+

+ + <%= article.title %> + +

+
+ "> + <%= crayons_icon_tag("twemoji/thumb-up", native: true, width: 16, height: 16) %> + <%= article.privileged_reaction_counts["thumbsup"] || "0" %> + + + "> + <%= crayons_icon_tag("twemoji/thumb-down", native: true, width: 16, height: 16) %> + <%= article.privileged_reaction_counts["thumbsdown"] || "0" %> + + + "> + <%= crayons_icon_tag("twemoji/suspicious", native: true, width: 16, height: 16) %> + <%= countable_vomits %> + + + "> + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <%= article.score %> + +
+
+ +
+
+ <% decorated_article.cached_tag_list_array.each do |tag| %> + #<%= tag %> + <% end %> +
+ + ❤️ <%= article.public_reactions_count %> <%= t("views.moderations.article.likes") %> + 💬 <%= article.comments_count %> <%= t("views.moderations.article.comments") %> +
+ +
+
+ @<%= article.user&.username %> + <% if article.published_from_feed? && !article.published? %> + RSS Import <%= article.created_at.strftime("%b %d, %Y") %> + Originally Published <%= article.published_at&.strftime("%b %d, %Y") %> + <% elsif article.crossposted_at? %> + Crossposted <%= article.crossposted_at.strftime("%b %d, %Y") %> & Published + <%= article.published_at&.strftime("%b %d, %Y") %> + <% else %> + <%= article.published_at&.strftime("%b %d, %Y") %> + <% end %> +
+ +
+ <%= t("views.moderations.article.view_details") %> + <% if decorated_article.pinned? %> +
+ + + +
+ <% else %> +
+ + +
+ <% end %> + <%= t("views.moderations.article.user") %> + <%= t("views.moderations.article.edit") %> +
+
+
+ + <% cache "admin-user-notes-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> + <% if article.user&.notes&.any? %> +
+
+

<%= article.user&.notes&.size %> <%= t("views.moderations.article.notes.user_notes") %>

+ <%= t("views.moderations.article.notes.view_all") %> +
+ <% article.user&.notes&.last(3)&.each do |note| %> +

+ <%= note.content %> + + – <%= note.author_id ? User.find(note.author_id).username : "No author" %> + + +

+ <% end %> +
+ <% end %> + <% end %> + + <%= form_with url: admin_article_path(article.id), model: article, local: true, class: "crayons-card crayons-card--secondary p-4 flex flex-col gap-3" do |f| %> + + + +
+
+ + +
+ +
+ + "> +
+
+ <% if article.published? %> +
+
+ +
+ <%= f.datetime_select :published_at, { required: true, include_blank: true, include_seconds: true }, class: "crayons-select w-auto" %> + UTC +
+
+
+ <% end %> +
+
+ <%= f.check_box :featured, id: "featured-#{article.id}", class: "crayons-checkbox" %> + +
+ +
+ <%= f.check_box :approved, id: "approved-#{article.id}", class: "crayons-checkbox" %> + +
+
+
+ +
+ <% end %> +
diff --git a/app/views/admin/articles/_flag_action_item.html.erb b/app/views/admin/articles/_flag_action_item.html.erb new file mode 100644 index 000000000..6b875c233 --- /dev/null +++ b/app/views/admin/articles/_flag_action_item.html.erb @@ -0,0 +1,49 @@ +
+
+
+
+ <%= crayons_icon_tag("twemoji/suspicious", native: true, width: 24, height: 24) %> +
+
+

<%= vomit_reaction.user.name %>

+ +
+
+ + <%# Note: There are discrepancies in status naming between the frontend (FE) and backend (BE). %> + <%# In the FE, 'Open/Unresolved' corresponds to 'valid' in the BE. This status appears when a trusted user flags an article, with a score of -50. %> + <%# In the FE, 'Valid' corresponds to 'confirmed' in the BE. This status appears when an admin user flags an article or marks a trusted user-flagged article as valid, with a score of -100. %> + <%# In both the FE and BE, 'Invalid' remains the same. This status appears when an admin user marks a flag or vomit reaction as invalid, with a score of 0. %> +
+ <% if vomit_reaction.status == 'valid' %> + "> + <%= t("views.moderations.flags.open.value") %> + + <% elsif vomit_reaction.status == 'invalid' %> + "> + <%= t("views.moderations.flags.invalid.value") %> + + <% elsif vomit_reaction.status == 'confirmed' %> + "> + <%= t("views.moderations.flags.valid.value") %> + + <% else %> + <%# This case should never arise. %> + "> + <%= t("views.moderations.flags.unidentified.value") %> + + <% end %> + + + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <%= vomit_reaction.points %> + +
+
+ <%= render "admin/articles/priviledged_reaction_actions_dropdown", vomit_reaction: vomit_reaction %> +
diff --git a/app/views/admin/articles/_individual_article.html.erb b/app/views/admin/articles/_individual_article.html.erb index 311470e83..bcab51d3f 100644 --- a/app/views/admin/articles/_individual_article.html.erb +++ b/app/views/admin/articles/_individual_article.html.erb @@ -1,191 +1,252 @@ <% decorated_article = article.decorate %> +<%= javascript_packs_with_chunks_tag "admin/articles/vomitReactionItem", defer: true %> +
-
- -
- <% if decorated_article.pinned? || article.featured || article.approved || (!article.published? && article.published_from_feed?) || article.video || article.user&.warned? %> -
- <% if !article.published? && article.published_from_feed? %> - Unpublished - <% end %> - <% if decorated_article.pinned? %> - Pinned - <% end %> - <% if article.featured %> - Featured - <% end %> - <% if article.approved %> - Approved - <% end %> - <% if article.video %> - Contains video - <% end %> - <% cache "admin-user-info-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> - <% if article.user&.warned? %> - User warned - <% end %> - <% end %> -
- <% end %> - - <% if article.main_image.present? %> - - - - <% end %> - -
-

- - <%= article.title %> - -

-
- - <%= crayons_icon_tag("twemoji/thumb-up", native: true, title: t("views.moderations.actions.thumb_up"), width: 16, height: 16) %> - <%= article.privileged_reaction_counts['thumbsup'] || '0' %> - - - - <%= crayons_icon_tag("twemoji/thumb-down", native: true, title: t("views.moderations.actions.thumb_down"), width: 16, height: 16) %> - <%= article.privileged_reaction_counts['thumbsdown'] || '0' %> - - - - <%= crayons_icon_tag("twemoji/suspicious", native: true, title: "Vomit", width: 16, height: 16) %> - <%= article.privileged_reaction_counts['vomit'] || '0' %> - - - - <%= crayons_icon_tag("analytics", native: true, title: "Score", width: 16, height: 16) %> - <%= article.score %> - -
-
- -
-
- <% decorated_article.cached_tag_list_array.each do |tag| %> - #<%= tag %> - <% end %> -
- - ❤️ <%= article.public_reactions_count %> likes - 💬 <%= article.comments_count %> comments -
- -
-
- @<%= article.user&.username %> - <% if article.published_from_feed? && !article.published? %> - RSS Import <%= article.created_at.strftime("%b %d, %Y") %> - Originally Published <%= article.published_at&.strftime("%b %d, %Y") %> - <% elsif article.crossposted_at? %> - Crossposted <%= article.crossposted_at.strftime("%b %d, %Y") %> & Published - <%= article.published_at&.strftime("%b %d, %Y") %> - <% else %> - <%= article.published_at&.strftime("%b %d, %Y") %> - <% end %> -
- -
- <% if decorated_article.pinned? %> -
- - - -
- <% else %> -
- - -
- <% end %> - User - Edit -
-
-
- - <% cache "admin-user-notes-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> - <% if article.user&.notes&.any? %> -
-
-

<%= article.user&.notes&.size %> user notes

- View all -
- <% article.user&.notes&.last(3)&.each do |note| %> -

- <%= note.content %> - - – <%= note.author_id ? User.find(note.author_id).username : "No author" %> - - -

- <% end %> -
+ <% privileged_article_reactions = article.reactions.privileged_category.select { |reaction| reaction.reactable_type == "Article" } %> + <% vomit_article_reactions = privileged_article_reactions.select { |reaction| reaction.category == "vomit" }.reverse %> + <% quality_article_reactions = (privileged_article_reactions - vomit_article_reactions).reverse %> + <% countable_vomits = 0 %> + <% if vomit_article_reactions.present? %> + <% vomit_article_reactions.each do |vomit_reaction| %> + <% if vomit_reaction.status != 'invalid' %> + <% countable_vomits += 1 %> + <% end %> <% end %> <% end %> - <%= form_with url: admin_article_path(article.id), model: article, local: true, class: "crayons-card crayons-card--secondary p-4 flex flex-col gap-3" do |f| %> - - - -
-
- - +
+
+ <% if decorated_article.pinned? || article.featured || article.approved || (!article.published? && article.published_from_feed?) || article.video || article.user&.warned? %> +
+ <% if !article.published? && article.published_from_feed? %> + <%= t("views.moderations.article.unplublished") %> + <% end %> + <% if decorated_article.pinned? %> + <%= t("views.moderations.article.pinned") %> + <% end %> + <% if article.featured %> + <%= t("views.moderations.article.featured") %> + <% end %> + <% if article.approved %> + <%= t("views.moderations.article.approved") %> + <% end %> + <% if article.video %> + <%= t("views.moderations.article.contains_video") %> + <% end %> + <% cache "admin-user-info-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> + <% if article.user&.warned? %> + <%= t("views.moderations.article.user_warned") %> + <% end %> + <% end %> +
+ <% end %> + + <% if article.main_image.present? %> + + + + <% end %> + +
+

+ + <%= article.title %> + +

+
+ "> + <%= crayons_icon_tag("twemoji/thumb-up", native: true, width: 16, height: 16) %> + <%= article.privileged_reaction_counts["thumbsup"] || "0" %> + + + "> + <%= crayons_icon_tag("twemoji/thumb-down", native: true, width: 16, height: 16) %> + <%= article.privileged_reaction_counts["thumbsdown"] || "0" %> + + + "> + <%= crayons_icon_tag("twemoji/suspicious", native: true, width: 16, height: 16) %> + <%= countable_vomits %> + + + "> + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <%= article.score %> + +
-
- - "> +
+
+ <% decorated_article.cached_tag_list_array.each do |tag| %> + #<%= tag %> + <% end %> +
+ + ❤️ <%= article.public_reactions_count %> <%= t("views.moderations.article.likes") %> + 💬 <%= article.comments_count %> <%= t("views.moderations.article.comments") %>
-
- <% if article.published? %> -
-
- -
- <%= f.datetime_select :published_at, { required: true, include_blank: true, include_seconds: true }, class: "crayons-select w-auto" %> - UTC + +
+
+ @<%= article.user&.username %> + <% if article.published_from_feed? && !article.published? %> + RSS Import <%= article.created_at.strftime("%b %d, %Y") %> + Originally Published <%= article.published_at&.strftime("%b %d, %Y") %> + <% elsif article.crossposted_at? %> + Crossposted <%= article.crossposted_at.strftime("%b %d, %Y") %> & Published + <%= article.published_at&.strftime("%b %d, %Y") %> + <% else %> + <%= article.published_at&.strftime("%b %d, %Y") %> + <% end %> +
+ +
+ <% if decorated_article.pinned? %> +
+ + + +
+ <% else %> +
+ + +
+ <% end %> + User + Edit +
+
+
+ + <% cache "admin-user-notes-#{article.user_id}-#{article.user&.updated_at}", expires_in: 4.hours do %> + <% if article.user&.notes&.any? %> +
+
+

<%= article.user&.notes&.size %> <%= t("views.moderations.article.notes.user_notes") %>

+ <%= t("views.moderations.article.notes.view_all") %> +
+ <% article.user&.notes&.last(3)&.each do |note| %> +

+ <%= note.content %> + + – <%= note.author_id ? User.find(note.author_id).username : "No author" %> + + +

+ <% end %> +
+ <% end %> + <% end %> + + <%= form_with url: admin_article_path(article.id), model: article, local: true, class: "crayons-card crayons-card--secondary p-4 flex flex-col gap-3" do |f| %> + + + +
+
+ + +
+ +
+ + "> +
+
+ <% if article.published? %> +
+
+ +
+ <%= f.datetime_select :published_at, { required: true, include_blank: true, include_seconds: true }, class: "crayons-select w-auto" %> + UTC +
+ <% end %> +
+
+ <%= f.check_box :featured, id: "featured-#{article.id}", class: "crayons-checkbox" %> + +
+ +
+ <%= f.check_box :approved, id: "approved-#{article.id}", class: "crayons-checkbox" %> + +
+
+
+
<% end %> -
-
- <%= f.check_box :featured, id: "featured-#{article.id}", class: "crayons-checkbox" %> - -
+
-
- <%= f.check_box :approved, id: "approved-#{article.id}", class: "crayons-checkbox" %> - -
-
+
+

<%= t("views.moderations.priviliged_actions.title") %>

+

<%= t("views.moderations.priviliged_actions.description") %>

+ + + +
+ <% 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 %>
- -
- <% 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 @@ +
+ +
+
    + <% if vomit_reaction.status != 'confirmed' %> +
  • + +
  • + <% end %> + <% if vomit_reaction.status != 'invalid' %> +
  • + +
  • + <% end %> +
+
+
diff --git a/app/views/admin/articles/_quality_action_item.html.erb b/app/views/admin/articles/_quality_action_item.html.erb new file mode 100644 index 000000000..f02f62da5 --- /dev/null +++ b/app/views/admin/articles/_quality_action_item.html.erb @@ -0,0 +1,18 @@ +
+
+
+ <%= crayons_icon_tag("twemoji/#{quality_reaction.category == 'thumbsup' ? 'thumb-up' : 'thumb-down'}", native: true, title: t("views.moderations.actions.#{quality_reaction.category == 'thumbsup' ? 'thumb_up' : 'thumb_down'}"), width: 24, height: 24) %> +
+
+

<%= quality_reaction.user.name %>

+ +
+
+ + + <%= crayons_icon_tag("analytics", native: true, width: 16, height: 16) %> + <%= quality_reaction.points %> + +
diff --git a/app/views/admin/articles/index.html.erb b/app/views/admin/articles/index.html.erb index df4eaee72..bc1440497 100644 --- a/app/views/admin/articles/index.html.erb +++ b/app/views/admin/articles/index.html.erb @@ -20,7 +20,7 @@ <%= paginate @articles %> -

<%= 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)