diff --git a/app/assets/images/book.svg b/app/assets/images/book.svg new file mode 100644 index 000000000..2506a4445 --- /dev/null +++ b/app/assets/images/book.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/chevron-right.svg b/app/assets/images/chevron-right.svg new file mode 100644 index 000000000..08ba384a6 --- /dev/null +++ b/app/assets/images/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/chevron-toggle.svg b/app/assets/images/chevron-toggle.svg new file mode 100755 index 000000000..302024376 --- /dev/null +++ b/app/assets/images/chevron-toggle.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/emoji/emoji-one-thumbs-up-gray.png b/app/assets/images/emoji/emoji-one-thumbs-up-gray.png new file mode 100644 index 000000000..9efcb22eb Binary files /dev/null and b/app/assets/images/emoji/emoji-one-thumbs-up-gray.png differ diff --git a/app/assets/images/emoji/emoji-one-thumbs-up.png b/app/assets/images/emoji/emoji-one-thumbs-up.png new file mode 100644 index 000000000..4e7929a76 Binary files /dev/null and b/app/assets/images/emoji/emoji-one-thumbs-up.png differ diff --git a/app/assets/images/mod-filled.svg b/app/assets/images/mod-filled.svg new file mode 100644 index 000000000..8dacf8301 --- /dev/null +++ b/app/assets/images/mod-filled.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/plus.svg b/app/assets/images/plus.svg new file mode 100644 index 000000000..3674df13e --- /dev/null +++ b/app/assets/images/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/subtract.svg b/app/assets/images/subtract.svg new file mode 100644 index 000000000..4e802d543 --- /dev/null +++ b/app/assets/images/subtract.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/javascripts/initializers/initializeBaseUserData.js b/app/assets/javascripts/initializers/initializeBaseUserData.js index 3c8118628..801fbf257 100644 --- a/app/assets/javascripts/initializers/initializeBaseUserData.js +++ b/app/assets/javascripts/initializers/initializeBaseUserData.js @@ -55,11 +55,6 @@ function addRelevantButtonsToArticle(user) { ); } document.getElementById('action-space').innerHTML = actions.join(''); - } else if (user.trusted) { - document.getElementById('action-space').innerHTML = - 'MODERATE POST'; } } } @@ -99,6 +94,20 @@ function addRelevantButtonsToComments(user) { function initializeBaseUserData() { const user = userData(); + const userProfileLinkHTML = + '
' + + '' + + user.name + + '' + + '@' + + user.username + + '' + + '
'; + document.getElementById( + 'user-profile-link-placeholder', + ).innerHTML = userProfileLinkHTML; const userNavLink = document.getElementById('first-nav-link'); userNavLink.href = `/${user.username}`; userNavLink.querySelector('span').textContent = user.name; diff --git a/app/assets/stylesheets/article-show.scss b/app/assets/stylesheets/article-show.scss index 652385c4d..64ece45b6 100644 --- a/app/assets/stylesheets/article-show.scss +++ b/app/assets/stylesheets/article-show.scss @@ -31,6 +31,92 @@ height: 10px; } +.mod-actions-menu { + position: fixed; + top: var(--header-height); + right: 0; + height: calc(100vh - var(--header-height)); + z-index: 200; + transform: translateX(360px); + transition: var(--transition-props); + &.showing { + transform: initial; + } + + iframe { + height: 100%; + border: none; + box-shadow: -4px 0px 15px rgba(0, 0, 0, 0.1); + width: 360px; + } +} + +.mod-actions-menu-btn { + position: fixed; + bottom: var(--su-7); + right: var(--su-7); + z-index: 201; + cursor: pointer; + transition: 0.3s all ease; + border-radius: 50px; + + @media (max-width: $breakpoint-m) { + bottom: 90px; + } +} + +.actions-menu-svg { + color: white; +} + +.mod-actions-menu-btn, +.actions-menu-svg { + transition: 0.3s all ease; +} + +.actions-menu-svg:hover { + color: white; +} + +.flag-user-modal { + .flag-user-modal-header { + padding-left: var(--su-4); + + h2 { + font-weight: var(--fw-bold); + font-size: var(--fs-l); + line-height: var(--lh-tight); + } + + .modal-header-close-icon { + border-radius: 100%; + background: var(--base-10); + } + } + + .flag-user-modal-body { + padding: var(--su-6); + + span { + font-size: var(--fs-base); + line-height: var(--lh-base); + color: var(--base-90); + } + + .crayons-fields { + margin-top: var(--su-4); + + .abuse-report-link { + margin: var(--su-4) 0 0 var(--su-7); + } + } + + .buttons-container { + margin-top: var(--su-8); + } + } +} + .video-player-header { background: $black; margin-top: 0; diff --git a/app/assets/stylesheets/moderators.scss b/app/assets/stylesheets/moderators.scss index bfceaa968..38844dacb 100644 --- a/app/assets/stylesheets/moderators.scss +++ b/app/assets/stylesheets/moderators.scss @@ -139,13 +139,17 @@ } .mod-index-row-iframes { padding: 5px 0px; + display: flex; iframe { height: calc(100vh - 100px); max-height: 800px; - width: 49%; + width: 100%; border: 0px; display: inline-block; } + .actions-panel { + width: 36%; + } } } } @@ -155,8 +159,452 @@ padding: 30px 10px; } +// these styles are to correct height and margin issues in the iframe +.moderations-actions_panel { + min-height: initial; + height: 100%; + #page-content-inner { + margin-bottom: 0; + height: 100%; + } +} + .mod-container { - padding: 30px 0px; + display: flex; + flex-direction: column; + background-color: var(--base-0); + border: none; + box-shadow: none; + margin-bottom: 0; + height: 100%; + .circle { + border-radius: 100%; + &.centered-icon { + display: flex; + align-items: center; + justify-content: center; + } + } + + header { + &.top-header { + background-color: var(--base-inverted); + padding: var(--su-4) var(--su-4) 0; + display: flex; + flex-direction: column; + } + &.other-things { + padding: var(--su-4); + h1 { + text-transform: uppercase; + font-size: var(--fs-s); + } + } + h1 { + font-size: var(--fs-l); + font-weight: var(--fw-bold); + } + h2 { + font-size: var(--fs-base); + font-weight: var(--fw-normal); + color: var(--base-70); + } + button.close-actions-panel { + border: none; + width: 40px; + height: 40px; + position: absolute; + right: var(--su-4); + top: var(--su-4); + background-color: var(--base-10); + } + } + .reactions-container { + padding: var(--su-4); + background-color: var(--base-inverted); + } + + .thumb-reactions-container { + display: grid; + grid-gap: var(--su-4); + grid-template-columns: 1fr 1fr; + } + + .reaction-button { + position: relative; + background: transparent; + border-color: transparent; + border: 0; + display: flex; + flex-direction: column; + align-items: center; + height: var(--su-10); + padding: var(--su-4) var(--su-6); + border-radius: var(--radius); + + &:hover { + background: rgba(59, 73, 223, 0.05); + } + + &:hover .reaction-button-circle { + border-color: var(--accent-brand); + } + } + + .reaction-button-circle { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + width: var(--su-9); + height: var(--su-9); + border: solid 2px var(--base-20); + border-radius: 100%; + background: var(--base-inverted); + + img { + width: var(--su-7); + } + + .reaction-checkmark { + opacity: 0; + position: absolute; + bottom: 0; + right: 0; + width: var(--su-5); + height: var(--su-5); + display: flex; + align-items: center; + justify-content: center; + background-color: var(--accent-success); + border-radius: 100%; + border: 2px solid var(--base-inverted); + padding: 2px; + color: var(--base-inverted); + } + } + + .vote-text { + font-weight: var(--fw-bold); + font-size: var(--fs-base); + line-height: var(--lh-base); + margin-top: var(--su-2); + color: var(--base-90); + } + + .reaction-vomit-button { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--base-inverted); + height: var(--su-7); + padding: var(--su-1) var(--su-2); + margin: var(--su-4) auto; + border-radius: var(--radius); + + img { + margin-right: var(--su-2); + width: var(--su-6); + height: var(--su-6); + } + + span { + font-weight: var(--fw-medium); + font-size: var(--fs-s); + line-height: var(--lh-tight); + color: var(--base-90); + } + + .vomit-checkmark { + width: var(--su-5); + height: auto; + font-weight: var(--fw-heavy); + margin-left: var(--su-2); + color: var(--accent-brand); + display: none; + } + + &:hover { + background: rgba(59, 73, 223, 0.05); + } + } + + .how-this-works-section { + font-weight: var(--fw-medium); + font-size: var(--fs-xs); + line-height: var(--lh-base); + letter-spacing: -0.02em; + color: var(--base-70); + cursor: pointer; + padding: var(--su-4) var(--su-3); + display: flex; + justify-content: center; + align-items: center; + svg { + width: var(--su-4); + height: var(--su-4); + padding: var(--su-1); + path { + fill: var(--base-70); + } + } + } + + .reaction-button.reacted { + background: var(--accent-brand); + + .reaction-button-circle { + border-color: var(--accent-brand-lighter); + } + + .vote-text { + color: var(--base-inverted); + } + + .reaction-checkmark { + opacity: 100; + } + } + + .reaction-vomit-button.reacted { + background: rgba(59, 73, 223, 0.1); + + .vomit-checkmark { + display: inline; + } + + span { + color: var(--accent-brand); + + &:after { + content: 'Marked as abuse/spam'; + } + } + } + + .reaction-button .reacted-emoji, + .reaction-vomit-button .reacted-emoji { + display: none; + } + + .reaction-button.reacted .emoji-grey, + .reaction-vomit-button.reacted .emoji-grey { + display: none; + } + + .reaction-button.reacted .reacted-emoji, + .reaction-vomit-button.reacted .reacted-emoji { + display: block; + } + .other-things-container { + border-top: 1px solid var(--base-20); + overflow-y: auto; + button.other-things-btn { + background: inherit; + border: none; + padding: var(--su-3) var(--su-4); + width: 100%; + display: flex; + justify-content: space-between; + .label-wrapper { + display: flex; + align-items: center; + .icon { + background-color: var(--base-20); + width: 40px; + height: 40px; + } + header { + text-align: left; + margin-left: var(--su-3); + display: flex; + flex-direction: column; + width: 240px; + h2 { + color: var(--base-100); + font-size: var(--fs-base); + font-weight: var(--fw-medium); + } + h3 { + color: var(--base-70); + font-size: var(--fs-s); + font-weight: var(--fw-normal); + } + } + } + .toggle-chevron-container { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + align-self: center; + transition-duration: 0.4s; + svg { + fill: var(--base-80); + } + &.rotated { + transform: rotateZ(-180deg); + } + } + &:hover { + background-color: var(--base-inverted); + .label-wrapper > .icon { + background-color: var(--accent-brand); + svg { + fill: var(--base-inverted); + } + } + header h2 { + color: var(--accent-brand); + } + } + &.active { + header h2 { + font-weight: var(--fw-bold); + } + } + &.inactive:not(.active) { + header h2 { + color: var(--base-50); + } + header h3 { + color: var(--base-40); + } + .icon { + background-color: var(--base-20); + svg { + fill: var(--base-50); + } + } + .toggle-chevron-container > svg { + fill: var(--base-60); + } + } + } + .dropdown-options { + display: flex; + flex-direction: column; + padding: 0 var(--su-4); + transition-duration: 1s; + } + .adjust-tags-options { + .add-tag-container { + padding: var(--su-2) var(--su-1); + } + button.adjustable-tag { + background: inherit; + border: none; + padding: var(--su-2) 2px; + font-size: var(--fs-base); + text-align: left; + color: var(--base-80); + font-family: var(--ff-accent); + .num-sign { + color: var(--base-40); + padding-left: 2px; + } + .adjustment-icon { + width: 24px; + height: 24px; + float: right; + margin-right: 2px; + &.subtract { + background: var(--accent-danger); + } + &.plus { + background: var(--accent-success-darker); + } + svg > path { + fill: var(--base-inverted); + } + } + &:hover { + background: rgba(59, 73, 223, 0.05); + } + &.active { + background: rgba(59, 73, 223, 0.05); + } + // &.selected {} + } + .adjustment-reason-container { + display: flex; + flex-direction: column; + padding: var(--su-1); + textarea { + margin-bottom: var(--su-2); + } + } + } + .set-experience-options { + button.level-rating-button { + background: inherit; + border: none; + padding: var(--su-2) 0.125rem; + width: 100%; + display: flex; + justify-content: space-between; + + &:hover { + background: rgba(59, 73, 223, 0.05); + } + + .level-rating-content { + display: flex; + + .level-rating-number { + display: flex; + align-items: center; + justify-content: center; + font-weight: var(--fw-heavy); + font-size: var(--fs-base); + line-height: var(--lh-base); + color: var(--base-100); + width: var(--su-6); + } + + .level-rating-text { + font-weight: var(--fw-normal); + font-size: var(--fs-base); + line-height: var(--lh-base); + color: var(--base-90); + } + } + + svg { + color: var(--accent-brand); + width: var(--su-5); + height: auto; + margin-right: var(--su-2); + align-self: center; + display: none; + } + } + + .level-rating-button.selected { + background: rgba(59, 73, 223, 0.05); + svg { + display: inline; + } + } + } + } + .bottom-actions { + display: flex; + justify-content: center; + padding-bottom: var(--su-4); + margin-top: auto; + button { + display: initial; + font-size: var(--fs-base); + color: var(--accent-brand); + border: none; + background: none; + } + } + h2 { a { color: var(--body-color); @@ -184,39 +632,14 @@ margin: 90px auto; border-radius: 12px; } - .reaction-button { - position: relative; - width: 80px; - height: 80px; - background: white; - border-color: white; - margin: 30px 30px; - border-radius: 200px; - border: 0px; - padding: 12px 12px; - &:active { - border: 0px; - } - } - - .reaction-button img { - position: absolute; - top: 5px; - width: 70px; - left: 5px; - } - - .reaction-button .reacted-emoji { - display: none; - } - - .reaction-button.reacted .reacted-emoji { - display: block; - } .tag-mod-form { border-top: 1px solid #dddddd; margin: 42px auto; + + .level-rating-button.article-mod-page.selected { + background: #9bebff; + } } .tag-mod-form ul { @@ -272,19 +695,6 @@ border: 1px solid var(--card-color-tertiary); } - .level-rating-button { - border-radius: 8px; - font-size: 1.1em; - padding: 3px; - width: 42px; - border: 2px solid black; - font-weight: bold; - } - - .level-rating-button.selected { - background: #9bebff; - } - input.undo { background: $red; } diff --git a/app/controllers/internal/application_controller.rb b/app/controllers/internal/application_controller.rb index 6cde44f74..f03ec7a29 100644 --- a/app/controllers/internal/application_controller.rb +++ b/app/controllers/internal/application_controller.rb @@ -16,7 +16,7 @@ class Internal::ApplicationController < ApplicationController { name: "listings", controller: "listings" }, { name: "moderator_actions", controller: "moderator_actions" }, { name: "mods", controller: "mods" }, - { name: "negative_reactions", controller: "negative_reactions" }, + { name: "privileged_reactions", controller: "privileged_reactions" }, { name: "organizations", controller: "organizations" }, { name: "path_redirects", controller: "path_redirects" }, { name: "pages", controller: "pages" }, diff --git a/app/controllers/internal/negative_reactions_controller.rb b/app/controllers/internal/negative_reactions_controller.rb deleted file mode 100644 index 97bab9747..000000000 --- a/app/controllers/internal/negative_reactions_controller.rb +++ /dev/null @@ -1,15 +0,0 @@ -class Internal::NegativeReactionsController < Internal::ApplicationController - layout "internal" - - NEGATIVE_REACTION_CATEGORIES = %i[vomit thumbsdown].freeze - - def index - @q = Reaction. - includes(:user, - :reactable). - where("category IN (?)", NEGATIVE_REACTION_CATEGORIES). - order("reactions.created_at DESC"). - ransack(params[:q]) - @negative_reactions = @q.result.page(params[:page] || 1).per(25) - end -end diff --git a/app/controllers/internal/privileged_reactions_controller.rb b/app/controllers/internal/privileged_reactions_controller.rb new file mode 100644 index 000000000..f3b10422e --- /dev/null +++ b/app/controllers/internal/privileged_reactions_controller.rb @@ -0,0 +1,15 @@ +class Internal::PrivilegedReactionsController < Internal::ApplicationController + layout "internal" + + PRIVILEGED_REACTION_CATEGORIES = %i[thumbsup thumbsdown vomit].freeze + + def index + @q = Reaction. + includes(:user, + :reactable). + where("category IN (?)", PRIVILEGED_REACTION_CATEGORIES). + order("reactions.created_at DESC"). + ransack(params[:q]) + @privileged_reactions = @q.result.page(params[:page] || 1).per(25) + end +end diff --git a/app/controllers/moderations_controller.rb b/app/controllers/moderations_controller.rb index 9ca5f1d75..462577f1c 100644 --- a/app/controllers/moderations_controller.rb +++ b/app/controllers/moderations_controller.rb @@ -15,6 +15,31 @@ class ModerationsController < ApplicationController end def article + load_article + render template: "moderations/mod" + end + + def comment + authorize(User, :moderation_routes?) + @moderatable = Comment.find(params[:id_code].to_i(26)) + render template: "moderations/mod" + end + + def actions_panel + load_article + tag_mod_tag_ids = @tag_moderator_tags.pluck(:id) + has_room_for_tags = @moderatable.tag_list.size < 4 + has_no_relevant_adjustments = @adjustments.pluck(:tag_id).intersection(tag_mod_tag_ids).size.zero? + can_be_adjusted = @moderatable.tags.pluck(:id).intersection(tag_mod_tag_ids).size.positive? + + @should_show_adjust_tags = tag_mod_tag_ids.size.positive? && ((has_room_for_tags && has_no_relevant_adjustments) || (!has_room_for_tags && has_no_relevant_adjustments && can_be_adjusted)) + + render template: "moderations/actions_panel" + end + + private + + def load_article authorize(User, :moderation_routes?) @tag_adjustment = TagAdjustment.new @moderatable = Article.find_by(slug: params[:slug]) @@ -24,12 +49,5 @@ class ModerationsController < ApplicationController @already_adjusted_tags = @adjustments.map(&:tag_name).join(", ") @allowed_to_adjust = @moderatable.class.name == "Article" && (current_user.has_role?(:super_admin) || @tag_moderator_tags.any?) @hidden_comments = @moderatable.comments.where(hidden_by_commentable_user: true) - render template: "moderations/mod" - end - - def comment - authorize(User, :moderation_routes?) - @moderatable = Comment.find(params[:id_code].to_i(26)) - render template: "moderations/mod" end end diff --git a/app/controllers/rating_votes_controller.rb b/app/controllers/rating_votes_controller.rb index b90482de2..ba6edd4e2 100644 --- a/app/controllers/rating_votes_controller.rb +++ b/app/controllers/rating_votes_controller.rb @@ -12,9 +12,15 @@ class RatingVotesController < ApplicationController rating_vote.rating = rating_vote_params[:rating].to_f rating_vote.group = rating_vote_params[:group] if rating_vote.save - redirect_back(fallback_location: "/mod") + respond_to do |format| + format.json { render json: { result: "Success" } } + format.html { redirect_back(fallback_location: "/mod") } + end else - render json: { result: "Not Upserted Successfully" } + respond_to do |format| + format.json { render json: { error: rating_vote.errors.full_messages.to_sentence }, status: :unprocessable_entity } + format.html { render json: { result: "Not Upserted Successfully" } } + end end end diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb index 19e6ed94a..e78ba3b60 100644 --- a/app/controllers/reactions_controller.rb +++ b/app/controllers/reactions_controller.rb @@ -3,6 +3,9 @@ class ReactionsController < ApplicationController before_action :authorize_for_reaction, :check_limit, only: [:create] after_action :verify_authorized + NEGATIVE_CATEGORIES = %w[thumbsdown vomit].freeze + MODERATION_CATEGORIES = %w[thumbsup thumbsdown vomit].freeze + def index skip_authorization @@ -51,7 +54,17 @@ class ReactionsController < ApplicationController def create remove_count_cache_key + if params[:reactable_type] == "Article" && params[:category].in?(MODERATION_CATEGORIES) + clear_moderator_reactions( + params[:reactable_id], + params[:reactable_type], + current_user, + params[:category], + ) + end + category = params[:category] || "like" + reaction = Reaction.where( user_id: current_user.id, reactable_id: params[:reactable_id], @@ -61,13 +74,7 @@ class ReactionsController < ApplicationController # if the reaction already exists, destroy it if reaction - result = destroy_reaction(reaction) - - if reaction.negative? && current_user.auditable? - updated_params = params.dup - updated_params[:action] = "destroy" - Audit::Logger.log(:moderator, current_user, updated_params) - end + result = handle_existing_reaction(reaction) else reaction = build_reaction(category) @@ -84,7 +91,7 @@ class ReactionsController < ApplicationController rate_article(reaction) end - if reaction.negative? && current_user.auditable? + if current_user.auditable? Audit::Logger.log(:moderator, current_user, params.dup) end else @@ -130,6 +137,30 @@ class ReactionsController < ApplicationController rating: current_user.experience_level) end + def clear_moderator_reactions(id, type, mod, category) + reactions = if category == "thumbsup" + Reaction.where(reactable_id: id, reactable_type: type, user: mod).where.not(category: category) + elsif category.in?(NEGATIVE_CATEGORIES) + Reaction.where(reactable_id: id, reactable_type: type, user: mod, category: "thumbsup") + end + + return if reactions.blank? + + reactions.find_each { |reaction| destroy_reaction(reaction) } + end + + def handle_existing_reaction(reaction) + result = destroy_reaction(reaction) + + if reaction.negative? && current_user.auditable? + updated_params = params.dup + updated_params[:action] = "destroy" + Audit::Logger.log(:moderator, current_user, updated_params) + end + + result + end + def check_limit rate_limit!(:reaction_creation) end diff --git a/app/controllers/tag_adjustments_controller.rb b/app/controllers/tag_adjustments_controller.rb index 436a084b6..ff6d59b0c 100644 --- a/app/controllers/tag_adjustments_controller.rb +++ b/app/controllers/tag_adjustments_controller.rb @@ -17,8 +17,13 @@ class TagAdjustmentsController < ApplicationController article = service.article if tag_adjustment.save service.update_tags_and_notify - redirect_to "#{URI.parse(article.path).path}/mod" + tag = tag_adjustment.tag + respond_to do |format| + format.json { render json: { status: "Success", result: tag_adjustment.adjustment_type, colors: { bg: tag.bg_color_hex, text: tag.text_color_hex } } } + format.html { redirect_to "#{URI.parse(article.path).path}/mod" } + end else + # TODO: remove this when we move over to full JSON endpoint authorize(User, :moderation_routes?) @tag_adjustment = tag_adjustment @moderatable = article @@ -26,7 +31,10 @@ class TagAdjustmentsController < ApplicationController @adjustments = TagAdjustment.where(article_id: article.id) @already_adjusted_tags = @adjustments.map(&:tag_name).join(", ") @allowed_to_adjust = @moderatable.class.name == "Article" && (current_user.any_admin? || @tag_moderator_tags.any?) - render template: "moderations/mod" + respond_to do |format| + format.json { render json: { error: "Failure: #{tag_adjustment.errors.full_messages.to_sentence}" } } + format.html { render template: "moderations/mod" } + end end end @@ -37,6 +45,10 @@ class TagAdjustmentsController < ApplicationController @article = Article.find(tag_adjustment.article_id) @article.update!(tag_list: @article.tag_list.add(tag_adjustment.tag_name)) if tag_adjustment.adjustment_type == "removal" @article.update!(tag_list: @article.tag_list.remove(tag_adjustment.tag_name)) if tag_adjustment.adjustment_type == "addition" - redirect_to "#{URI.parse(@article.path).path}/mod" + respond_to do |format| + # TODO: add tag adjustment removal async route in actions panel + format.json { render json: { result: "Tag adjustment destroyed" } } + format.html { redirect_to "#{URI.parse(@article.path).path}/mod" } + end end end diff --git a/app/javascript/actionsPanel/__tests__/actionsPanel.test.js b/app/javascript/actionsPanel/__tests__/actionsPanel.test.js new file mode 100644 index 000000000..c00e70633 --- /dev/null +++ b/app/javascript/actionsPanel/__tests__/actionsPanel.test.js @@ -0,0 +1,236 @@ +/* eslint-disable no-restricted-globals */ +import fetch from 'jest-fetch-mock'; +import { + addCloseListener, + initializeHeight, + addReactionButtonListeners, + addAdjustTagListeners, +} from '../actionsPanel'; + +describe('addCloseListener()', () => { + test('toggles the mod actions panel and its button on click', () => { + document.body.innerHTML = ` + +
+ +
+
+ +
+ + `; + addCloseListener(); + + const closeButton = document.querySelector('.close-actions-panel'); + closeButton.click(); + // eslint-disable-next-line no-restricted-globals + const modPanel = top.document.querySelector('.mod-actions-menu'); + const modPanelBtn = top.document.querySelector('.mod-actions-menu-btn'); + expect(modPanel.classList).not.toContain('showing'); + expect(modPanelBtn.classList).not.toContain('hidden'); + }); +}); + +describe('initializeHeight()', () => { + test('it sets the height of the proper elements', () => { + document.body.innerHTML = ` + +
+
+
+
+ + `; + initializeHeight(); + + const { body } = document; + expect(document.documentElement.style.height).toEqual('100%'); + expect(body.style.height).toEqual('100%'); + expect(body.style.margin).toEqual('0px'); + expect(body.style.marginTop).toEqual('0px'); + expect(body.style.marginBottom).toEqual('0px'); + expect(body.style.paddingTop).toEqual('0px'); + expect(body.style.paddingTop).toEqual('0px'); + }); +}); + +describe('addReactionButtonListeners()', () => { + beforeEach(() => { + fetch.resetMocks(); + + document.body.innerHTML = ` + + + + `; + + const csrfToken = 'this-is-a-csrf-token'; + + window.fetch = fetch; + window.getCsrfToken = async () => csrfToken; + top.addSnackbarItem = jest.fn(); + }); + + function sampleResponse(category, create = true) { + return JSON.stringify({ + outcome: { + result: create ? 'create' : 'destroy', + category, + }, + }); + } + + describe('when no reactions are already reacted on', () => { + test('it marks thumbs up reaction as reacted', async () => { + let category = 'thumbsup'; + fetch.mockResponse(sampleResponse(category)); + addReactionButtonListeners(); + + const thumbsupButton = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + thumbsupButton.click(); + expect(thumbsupButton.classList).toContain('reacted'); + + category = 'thumbsdown'; + const thumbsdownButton = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + thumbsdownButton.click(); + expect(thumbsdownButton.classList).toContain('reacted'); + + category = 'vomit'; + fetch.resetMocks(); + fetch.mockResponse(sampleResponse(category)); + const vomitButton = document.querySelector( + `.reaction-vomit-button[data-category="${category}"]`, + ); + vomitButton.click(); + expect(vomitButton.classList).toContain('reacted'); + }); + test('it unmarks the proper reaction(s) when positive/negative reactions are clicked', async () => { + let category = 'thumbsup'; + fetch.mockResponse(sampleResponse(category)); + addReactionButtonListeners(); + const thumbsupButton = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + thumbsupButton.click(); + + category = 'thumbsdown'; + fetch.resetMocks(); + fetch.mockResponse(sampleResponse(category)); + const thumbsdownButton = document.querySelector( + `.reaction-button[data-category="${category}"]`, + ); + thumbsdownButton.click(); + expect(thumbsupButton.classList).not.toContain('reacted'); + + fetch.resetMocks(); + category = 'thumbsup'; + fetch.mockResponse(sampleResponse(category, false)); + thumbsupButton.click(); + expect(thumbsdownButton.classList).not.toContain('reacted'); + expect(thumbsupButton.classList).toContain('reacted'); + + category = 'vomit'; + fetch.resetMocks(); + fetch.mockResponse(sampleResponse(category)); + const vomitButton = document.querySelector( + `.reaction-vomit-button[data-category="${category}"]`, + ); + vomitButton.click(); + expect(vomitButton.classList).toContain('reacted'); + expect(thumbsupButton.classList).not.toContain('reacted'); + }); + }); +}); + +describe('addAdjustTagListeners()', () => { + describe('when the user is tag moderator of #discuss', () => { + beforeEach(() => { + const tagName = 'discuss'; + document.body.innerHTML = ` + ${tagName} + + + `; + + addAdjustTagListeners(); + }); + describe('when an article is tagged with #discuss', () => { + it('toggles the tag button and the form', () => { + const tagBtn = document.querySelector('.adjustable-tag'); + tagBtn.click(); + expect(tagBtn.classList).toContain('active'); + expect( + document.getElementById('adjustment-reason-container').classList, + ).not.toContain('hidden'); + }); + it('hides the form if the button is clicked again', () => { + const tagBtn = document.querySelector('.adjustable-tag'); + tagBtn.click(); + tagBtn.click(); + expect( + document.getElementById('adjustment-reason-container').classList, + ).toContain('hidden'); + }); + }); + }); + + describe('when the user is an admin and the article has room for tags', () => { + describe('tag adjustment interactions', () => { + beforeEach(() => { + const tagName = 'discuss'; + document.body.innerHTML = ` +
+ +
+ ${tagName} + + + + `; + addAdjustTagListeners(); + }); + it('shows the adjustment container when admin input is focused', () => { + document.getElementById('admin-add-tag').focus(); + expect( + document.getElementById('adjustment-reason-container').classList, + ).not.toContain('hidden'); + }); + it('triggers a confirmation if the admin add tag input was filled in', () => { + window.confirm = jest.fn(); + document.getElementById('admin-add-tag').value = 'pizza'; + document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click(); + expect(window.confirm).toHaveBeenCalled(); + }); + it('does not the hide reason container when going from one tag to another tag', () => { + document.querySelector('.adjustable-tag').click(); + document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click(); + expect( + document.getElementById('adjustment-reason-container').classList, + ).not.toContain('hidden'); + }); + }); + }); +}); + +/* eslint-enable no-restricted-globals */ diff --git a/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js b/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js new file mode 100644 index 000000000..b056f1f3b --- /dev/null +++ b/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js @@ -0,0 +1,33 @@ +import initializeActionsPanel from '../initializeActionsPanelToggle'; + +describe('toggling the actions panel', () => { + describe('when the page is the article show page', () => { + document.body.innerHTML = ` +
+
+ `; + const path = '/fakeuser/fake-article-slug-1d3a'; + + test('it should render the mod actions menu button', () => { + initializeActionsPanel(path); + expect( + document.querySelector( + `iframe#mod-container[src="${path}/actions_panel"]`, + ), + ).toBeDefined(); + expect(document.querySelector(`.mod-actions-menu-btn`)).not.toBeNull(); + expect(document.querySelector(`.actions-menu-svg`)).not.toBeNull(); + }); + + test('it should have a click listener that toggles the appropriate classes', () => { + initializeActionsPanel(path); + const modActionsMenu = document.querySelector('.mod-actions-menu'); + const modActionsMenuBtn = document.querySelector('.mod-actions-menu-btn'); + + modActionsMenuBtn.click(); + + expect(modActionsMenu.classList.contains('showing')).toBeTruthy(); + expect(modActionsMenuBtn.classList.contains('hidden')).toBeTruthy(); + }); + }); +}); diff --git a/app/javascript/actionsPanel/actionsPanel.js b/app/javascript/actionsPanel/actionsPanel.js new file mode 100644 index 000000000..0ace80cba --- /dev/null +++ b/app/javascript/actionsPanel/actionsPanel.js @@ -0,0 +1,370 @@ +import { request } from '@utilities/http'; + +export function addCloseListener() { + const button = document.querySelector('.close-actions-panel'); + button.addEventListener('click', () => { + // getting the article show page document because this is called within an iframe + // eslint-disable-next-line no-restricted-globals + const articleDocument = top.document; + + articleDocument + .querySelector('.mod-actions-menu') + .classList.toggle('showing'); + articleDocument + .querySelector('.mod-actions-menu-btn') + .classList.toggle('hidden'); + }); +} + +export function initializeHeight() { + document.documentElement.style.height = '100%'; + document.body.style.cssText = 'height: 100%; margin: 0; padding-top: 0;'; + document.getElementById('page-content').style.cssText = + 'margin-top: 0 !important; margin-bottom: 0;'; +} + +function toggleDropdown(type) { + if (type === 'set-experience') { + document + .querySelector('.set-experience-options') + .classList.toggle('hidden'); + } else if (type === 'adjust-tags') { + document.querySelector('.adjust-tags-options').classList.toggle('hidden'); + } +} + +function applyReactedClass(category) { + const upVote = document.querySelector("[data-category='thumbsup']"); + const downVote = document.querySelector("[data-category='thumbsdown']"); + const vomitVote = document.querySelector("[data-category='vomit']"); + + if (category === 'thumbsup') { + downVote.classList.remove('reacted'); + vomitVote.classList.remove('reacted'); + } else { + upVote.classList.remove('reacted'); + } +} + +export function addReactionButtonListeners() { + const butts = Array.from( + document.querySelectorAll('.reaction-button, .reaction-vomit-button'), + ); + /* eslint-disable camelcase */ + butts.forEach((butt) => { + butt.addEventListener('click', async (event) => { + event.preventDefault(); + const { + reactableType: reactable_type, + category, + reactableId: reactable_id, + } = butt.dataset; + + applyReactedClass(category); + butt.classList.toggle('reacted'); + + try { + const response = await request('/reactions', { + method: 'POST', + body: { reactable_type, category, reactable_id }, + }); + + const outcome = await response.json(); + + let message; + /* eslint-disable no-restricted-globals */ + if (outcome.result === 'create' && outcome.category === 'thumbsup') { + message = 'This post will be more visible.'; + } else if ( + outcome.result === 'create' && + outcome.category === 'thumbsdown' + ) { + message = 'This post will be less visible.'; + } else if ( + outcome.result === 'create' && + outcome.category === 'vomit' + ) { + message = "You've flagged this post as abusive or spam."; + } else if (outcome.result === 'destroy') { + message = 'Your quality rating was removed.'; + } else if (outcome.error) { + message = `Error: ${outcome.error}`; + } + top.addSnackbarItem({ + message, + addCloseButton: true, + }); + /* eslint-enable no-restricted-globals */ + } catch (error) { + // eslint-disable-next-line no-alert + alert(error); + } + }); + }); + /* eslint-enable camelcase */ +} + +function clearExpLevels() { + Array.from( + document.getElementsByClassName('level-rating-button selected'), + ).forEach((el) => { + el.classList.remove('selected'); + }); +} + +async function updateExperienceLevel(currentUserId, articleId, rating, group) { + try { + const response = await request('/rating_votes', { + method: 'POST', + body: JSON.stringify({ + user_id: currentUserId, + article_id: articleId, + rating, + group, + }), + }); + + const outcome = await response.json(); + + if (outcome.result === 'Success') { + clearExpLevels(); + document + .getElementById(`js__rating__vote__${rating}`) + .classList.add('selected'); + } else { + // eslint-disable-next-line no-alert + alert(outcome.error); + } + } catch (error) { + // eslint-disable-next-line no-alert + alert(error); + } +} + +function toggleSubmitContainer() { + document + .getElementById('adjustment-reason-container') + .classList.toggle('hidden'); +} + +function clearAdjustmentReason() { + document.getElementById('tag-adjustment-reason').value = ''; +} + +function renderTagOnArticle(tagName, colors) { + /* eslint-disable no-restricted-globals */ + let articleTagsContainer; + if (top.document.location.pathname.endsWith('/mod')) { + [articleTagsContainer] = top.document + .getElementById('quick-mod-article') + .contentDocument.getElementsByClassName('tags'); + } else { + [articleTagsContainer] = top.document.getElementsByClassName('tags'); + } + /* eslint-enable no-restricted-globals */ + + const newTag = document.createElement('a'); + newTag.innerText = `#${tagName}`; + newTag.setAttribute('class', 'tag'); + newTag.setAttribute('href', `/t/${tagName}`); + newTag.style = `background-color: ${colors.bg}; color: ${colors.text};`; + + articleTagsContainer.appendChild(newTag); +} + +async function adjustTag(el) { + const reasonForAdjustment = document.getElementById('tag-adjustment-reason') + .value; + const body = { + tag_adjustment: { + // TODO: change to tag ID + tag_name: el.dataset.tagName || el.value, + article_id: el.dataset.articleId, + adjustment_type: + el.dataset.adjustmentType === 'subtract' ? 'removal' : 'addition', + reason_for_adjustment: reasonForAdjustment, + }, + }; + + try { + const response = await request('/tag_adjustments', { + method: 'POST', + body: JSON.stringify(body), + }); + + const outcome = await response.json(); + + if (outcome.status === 'Success') { + let adjustedTagName; + if (el.tagName === 'BUTTON') { + adjustedTagName = el.dataset.tagName; + el.remove(); + } else { + adjustedTagName = el.value; + // eslint-disable-next-line no-param-reassign + el.value = ''; + } + + toggleSubmitContainer(); + clearAdjustmentReason(); + + if (outcome.result === 'addition') { + renderTagOnArticle(adjustedTagName, outcome.colors); + } else { + // eslint-disable-next-line no-restricted-globals + const tagOnArticle = top.document.querySelector( + `.tag[href="/t/${adjustedTagName}"]`, + ); + tagOnArticle.remove(); + } + + // eslint-disable-next-line no-restricted-globals + top.addSnackbarItem({ + message: `The #${adjustedTagName} tag was ${ + outcome.result === 'addition' ? 'added' : 'removed' + }.`, + addCloseButton: true, + }); + } else { + // eslint-disable-next-line no-restricted-globals + top.addSnackbarItem({ + message: `An error occurred: ${outcome.error}`, + addCloseButton: true, + }); + } + } catch (error) { + // eslint-disable-next-line no-alert + alert(error); + } +} + +export function handleAdjustTagBtn(btn) { + const currentActiveTags = document.querySelectorAll( + 'button.adjustable-tag.active', + ); + const adminTagInput = document.getElementById('admin-add-tag'); + /* eslint-disable no-restricted-globals */ + /* eslint-disable no-alert */ + if ( + adminTagInput && + adminTagInput.value !== '' && + confirm( + 'This will clear your current "Add a tag" input. Do you want to continue?', + ) + ) { + /* eslint-enable no-restricted-globals */ + /* eslint-enable no-alert */ + adminTagInput.value = ''; + } else if (currentActiveTags.length > 0) { + currentActiveTags.forEach((tag) => { + if (tag !== btn) { + tag.classList.remove('active'); + } + btn.classList.toggle('active'); + }); + if (btn.classList.contains('active')) { + document + .getElementById('adjustment-reason-container') + .classList.remove('hidden'); + } else { + document + .getElementById('adjustment-reason-container') + .classList.add('hidden'); + } + } else { + btn.classList.toggle('active'); + toggleSubmitContainer(); + } +} + +function handleAdminInput() { + const addTagInput = document.getElementById('admin-add-tag'); + + if (addTagInput) { + addTagInput.addEventListener('focus', () => { + document + .getElementById('adjustment-reason-container') + .classList.remove('hidden'); + + const activeTagBtns = Array.from( + document.querySelectorAll('button.adjustable-tag.active'), + ); + activeTagBtns.forEach((btn) => { + btn.classList.remove('active'); + }); + }); + addTagInput.addEventListener('focusout', () => { + if (addTagInput.value === '') { + toggleSubmitContainer(); + } + }); + } +} + +export function addAdjustTagListeners() { + Array.from(document.getElementsByClassName('adjustable-tag')).forEach( + (btn) => { + btn.addEventListener('click', () => { + handleAdjustTagBtn(btn); + }); + }, + ); + + const adjustTagSubmitBtn = document.getElementById('tag-adjust-submit'); + if (adjustTagSubmitBtn) { + adjustTagSubmitBtn.addEventListener('click', (e) => { + e.preventDefault(); + const textArea = document.getElementById('tag-adjustment-reason'); + const dataSource = + document.querySelector('button.adjustable-tag.active') || + document.getElementById('admin-add-tag'); + + if (textArea.checkValidity()) { + adjustTag(dataSource); + } + }); + + handleAdminInput(); + } +} + +export function addBottomActionsListeners() { + addAdjustTagListeners(); + Array.from(document.getElementsByClassName('other-things-btn')).forEach( + (btn) => { + btn.addEventListener('click', () => { + btn.classList.toggle('active'); + const otherBtns = Array.from( + document.getElementsByClassName('other-things-btn'), + ).filter((otherBtn) => otherBtn !== btn); + otherBtns.forEach((otherBtn) => { + otherBtn.classList.toggle('inactive'); + }); + + btn.querySelector('.label-wrapper > .icon').classList.toggle('hidden'); + btn + .querySelector('.toggle-chevron-container') + .classList.toggle('rotated'); + toggleDropdown(btn.dataset.otherThingsType); + }); + }, + ); + + document.querySelectorAll('.level-rating-button').forEach((btn) => { + btn.addEventListener('click', () => { + updateExperienceLevel( + btn.dataset.userId, + btn.dataset.articleId, + btn.value, + btn.dataset.group, + ); + }); + }); +} + +export function initializeActionsPanel() { + initializeHeight(); + addCloseListener(); + addReactionButtonListeners(); + addBottomActionsListeners(); +} diff --git a/app/javascript/actionsPanel/initializeActionsPanelToggle.js b/app/javascript/actionsPanel/initializeActionsPanelToggle.js new file mode 100644 index 000000000..83f7b1ce6 --- /dev/null +++ b/app/javascript/actionsPanel/initializeActionsPanelToggle.js @@ -0,0 +1,24 @@ +/** This initializes the mod actions button on the article show page (app/views/articles/show.html.erb). */ +export default function initializeActionsPanel(user, path) { + const modActionsMenuHTML = ``; + const modActionsMenuIconHTML = `
+ Moderation +
+`; + + function toggleModActionsMenu() { + document.querySelector('.mod-actions-menu').classList.toggle('showing'); + document.querySelector('.mod-actions-menu-btn').classList.toggle('hidden'); + } + + document.querySelector('.mod-actions-menu').innerHTML = modActionsMenuHTML; + // eslint-disable-next-line no-restricted-globals + if (!top.document.location.pathname.endsWith('/mod')) { + document.getElementById( + 'mod-actions-menu-btn-area', + ).innerHTML = modActionsMenuIconHTML; + document + .querySelector('.mod-actions-menu-btn') + .addEventListener('click', toggleModActionsMenu); + } +} diff --git a/app/javascript/packs/actionsPanel.js b/app/javascript/packs/actionsPanel.js new file mode 100644 index 000000000..de712af6f --- /dev/null +++ b/app/javascript/packs/actionsPanel.js @@ -0,0 +1,9 @@ +const loadActionsPanel = async () => { + const { initializeActionsPanel } = await import( + '../actionsPanel/actionsPanel' + ); + + initializeActionsPanel(); +}; + +loadActionsPanel(); diff --git a/app/javascript/packs/articleModerationTools.js b/app/javascript/packs/articleModerationTools.js new file mode 100644 index 000000000..261034ea9 --- /dev/null +++ b/app/javascript/packs/articleModerationTools.js @@ -0,0 +1,31 @@ +/* eslint-disable no-restricted-globals */ +/* eslint-disable no-undef */ +const user = userData(); +const { authorId: articleAuthorId, path } = document.getElementById( + 'article-show-container', +).dataset; + +const initializeModerationsTools = async () => { + const { default: initializeActionsPanel } = await import( + '../actionsPanel/initializeActionsPanelToggle' + ); + const { default: initializeFlagUserModal } = await import('./flagUserModal'); + + // article show page + if ( + user.trusted && + user.id !== articleAuthorId && + !top.document.location.pathname.endsWith('/mod') + ) { + initializeActionsPanel(user, path); + initializeFlagUserModal(articleAuthorId); + // dev.to/mod + } else if (user.trusted && top.document.location.pathname.endsWith('/mod')) { + initializeActionsPanel(user, path); + initializeFlagUserModal(articleAuthorId); + } +}; + +initializeModerationsTools(); +/* eslint-enable no-restricted-globals */ +/* eslint-enable no-undef */ diff --git a/app/javascript/packs/articlePage.jsx b/app/javascript/packs/articlePage.jsx index 7d22b41fc..df71a980b 100644 --- a/app/javascript/packs/articlePage.jsx +++ b/app/javascript/packs/articlePage.jsx @@ -6,6 +6,9 @@ const snackZone = document.getElementById('snack-zone'); render(, snackZone, snackZone.firstElementChild); +// eslint-disable-next-line no-restricted-globals +top.addSnackbarItem = addSnackbarItem; + const userDataIntervalID = setInterval(async () => { const { user = null, userStatus } = document.body.dataset; diff --git a/app/javascript/packs/flagUserModal.js b/app/javascript/packs/flagUserModal.js new file mode 100644 index 000000000..6933b99e4 --- /dev/null +++ b/app/javascript/packs/flagUserModal.js @@ -0,0 +1,137 @@ +import { request } from '../utilities/http'; + +export default function initializeFlagUserModal(articleAuthorId) { + const flagUserModalHTML = ` +
+
+
+

Flag User

+ +
+
+ + Thanks for keeping DEV safe. Here is what you can do to flag this user: + +
+
+ + +
+ Report other inappropriate conduct +
+ +
+
+
+
+`; + + const toggleFlagUserModal = () => { + const modalContainer = document.querySelector('.flag-user-modal-container'); + modalContainer.classList.toggle('hidden'); + + if (!modalContainer.classList.contains('hidden')) { + window.scrollTo(0, 0); + document.querySelector('body').style.height = '100vh'; + document.querySelector('body').style.overflowY = 'hidden'; + } else { + document.querySelector('body').style.height = 'inherit'; + document.querySelector('body').style.overflowY = 'inherit'; + } + }; + + const modContainer = document.getElementById('mod-container'); + document.querySelector( + '.flag-user-modal-container', + ).innerHTML = flagUserModalHTML; + modContainer.addEventListener('load', () => { + modContainer.contentWindow.document + .getElementById('open-flag-user-modal') + .addEventListener('click', toggleFlagUserModal); + }); + + // Event listeners to Close the Modal + const closeModalElements = Array.from( + document.querySelectorAll( + '.crayons-modal__overlay, .modal-header-close-icon, #cancel-flag-user-action', + ), + ); + + closeModalElements.forEach((element) => { + element.addEventListener('click', toggleFlagUserModal); + }); + + document + .getElementById('confirm-flag-user-action') + .addEventListener('click', async (e) => { + e.preventDefault(); + const vomitAllOption = document.getElementById('vomit-all'); + + if (vomitAllOption.checked) { + const body = JSON.stringify({ + reactable_type: vomitAllOption.dataset.reactableType, + category: vomitAllOption.dataset.category, + reactable_id: vomitAllOption.dataset.reactableId, + }); + + try { + const response = await request('/reactions', { + method: 'POST', + body, + }); + + const outcome = await response.json(); + + if (outcome.result === 'create') { + // eslint-disable-next-line no-restricted-globals + top.addSnackbarItem({ + message: 'All posts by this author will be less visible.', + addCloseButton: true, + }); + } else if (outcome.result === null) { + // eslint-disable-next-line no-restricted-globals + top.addSnackbarItem({ + message: + "It seems you've already reduced the vibilsity of this author's posts.", + addCloseButton: true, + }); + } else { + // eslint-disable-next-line no-restricted-globals + top.addSnackbarItem({ + message: `Response from server: ${JSON.stringify(outcome)}`, + addCloseButton: true, + }); + } + } catch (error) { + // eslint-disable-next-line no-restricted-globals + top.addSnackbarItem({ message: error, addCloseButton: true }); + } + } else { + // eslint-disable-next-line no-restricted-globals + top.addSnackbarItem({ + message: 'No selection made!', + addCloseButton: true, + }); + } + toggleFlagUserModal(); + }); +} diff --git a/app/models/negative_reaction.rb b/app/models/privileged_reaction.rb similarity index 84% rename from app/models/negative_reaction.rb rename to app/models/privileged_reaction.rb index 29332d43f..8bc3a3580 100644 --- a/app/models/negative_reaction.rb +++ b/app/models/privileged_reaction.rb @@ -1,4 +1,4 @@ -class NegativeReaction < ApplicationRecord +class PrivilegedReaction < ApplicationRecord resourcify # This class exists to take advantage of Rolify for limiting authorization # on internal reports. diff --git a/app/models/reaction.rb b/app/models/reaction.rb index 93c5d30fc..bab1a92d9 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -4,7 +4,7 @@ class Reaction < ApplicationRecord SEARCH_SERIALIZER = Search::ReactionSerializer SEARCH_CLASS = Search::Reaction - CATEGORIES = %w[like readinglist unicorn thinking hands thumbsdown vomit].freeze + CATEGORIES = %w[like readinglist unicorn thinking hands thumbsup thumbsdown vomit].freeze PUBLIC_CATEGORIES = %w[like readinglist unicorn thinking hands].freeze REACTABLE_TYPES = %w[Comment Article User].freeze STATUSES = %w[valid invalid confirmed archived].freeze @@ -161,6 +161,7 @@ class Reaction < ApplicationRecord BASE_POINTS = { "vomit" => -50.0, + "thumbsup" => 5.0, "thumbsdown" => -10.0 }.freeze diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index a9469d207..b806eeb0b 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -122,7 +122,7 @@ class UserPolicy < ApplicationPolicy end def moderation_routes? - user.has_role?(:trusted) && !user.banned + (user.has_role?(:trusted) || minimal_admin?) && !user.banned end def permitted_attributes diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb index b20c10151..82ce52013 100644 --- a/app/views/articles/show.html.erb +++ b/app/views/articles/show.html.erb @@ -1,7 +1,11 @@ <% title @article.title_with_query_preamble(user_signed_in?) %> <%= render "shared/webcomponents_loader_script" %> -<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", defer: true %> +<% if user_signed_in? %> + <%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", "articleModerationTools", defer: true %> +<% else %> + <%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", defer: true %> +<% end %> <% cache("content-related-optional-scripts-#{@article.id}-#{@article.updated_at}-#{internal_navigation?}-#{user_signed_in?}", expires_in: 30.hours) do %> <% if @article.processed_html.include? "ltag_gist-liquid-tag" %> @@ -224,6 +228,10 @@ <% end %> <% end %> +
+
+ + <% cache("article-show-scripts", expires_in: 8.hours) do %> diff --git a/config/routes.rb b/config/routes.rb index a0c7b0093..9a378ad4b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,7 +55,7 @@ Rails.application.routes.draw do resources :pages, only: %i[index new create edit update destroy] resources :mods, only: %i[index update] resources :moderator_actions, only: %i[index] - resources :negative_reactions, only: %i[index] + resources :privileged_reactions, only: %i[index] resources :permissions, only: %i[index] resources :podcasts, only: %i[index edit update destroy] do member do @@ -450,6 +450,7 @@ Rails.application.routes.draw do get "/:username/:slug/:view" => "stories#show", :constraints => { view: /moderate/ } get "/:username/:slug/mod" => "moderations#article" + get "/:username/:slug/actions_panel" => "moderations#actions_panel" get "/:username/:slug/manage" => "articles#manage" get "/:username/:slug/edit" => "articles#edit" get "/:username/:slug/delete_confirm" => "articles#delete_confirm" diff --git a/config/webpack/environment.js b/config/webpack/environment.js index a79284b6f..aab13abb8 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -22,7 +22,7 @@ environment.splitChunks((config) => { __dirname, '../../app/javascript/crayons', ) /* global __dirname */, - '@utilities': path.resolve(__dirname, '../../app/javascript/utlities'), + '@utilities': path.resolve(__dirname, '../../app/javascript/utilities'), }, }, optimization: { diff --git a/db/schema.rb b/db/schema.rb index 83674b310..f3eb9cc83 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. diff --git a/docs/backend/metrics.md b/docs/backend/metrics.md index 299816647..b94083a37 100644 --- a/docs/backend/metrics.md +++ b/docs/backend/metrics.md @@ -7,9 +7,9 @@ title: Tracking Metrics We track point-in-time data by sending data points to Datadog using [Statdsd](https://github.com/DataDog/dogstatsd-ruby). -Currently, we run a daily metric fetching job in `fetch.rake` which is called via -the Heroku scheduler. If we wanted to do other frequencies like hourly, etc. we -could add similar tasks. +Currently, we run a daily metric fetching job in `fetch.rake` which is called +via the Heroku scheduler. If we wanted to do other frequencies like hourly, etc. +we could add similar tasks. The rake task calls `Metrics::RecordDailyUsageWorker.perform_async`, which performs algorithms to come up with data points to send, for example: @@ -22,8 +22,8 @@ If you want to create a new periodic data send, follow this pattern to do so. ## Vendor-Agnostic -While we currently are not vendor-agnostic in how we do this (Heroku/Datadog), it -is set up in a way that could become so in the future. The main pattern is +While we currently are not vendor-agnostic in how we do this (Heroku/Datadog), +it is set up in a way that could become so in the future. The main pattern is `Every x minutes/hours/etc. send aggregate data to warehouse where it can be examined on a timeseries basis`. This could, in the future, be bundled right into the platform using an open source timeseries database and data visualization. diff --git a/jsconfig.json b/jsconfig.json index 0630b5585..da867fcd6 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -3,7 +3,7 @@ "baseUrl": "./", "paths": { "@crayons/*": ["app/javascript/crayons/*"], - "@utilities/*": ["app/javascript/utlities/*"] + "@utilities/*": ["app/javascript/utilities/*"] } }, "exclude": ["node_modules", "**/node_modules/*"] diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 5c5ed450b..b1752232d 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -38,4 +38,16 @@ RSpec.describe UserPolicy, type: :policy do it { is_expected.to forbid_actions(%i[moderation_routes]) } end + + context "when the user is an admin" do + let(:user) { build(:user, :admin) } + + it { is_expected.to permit_actions(%i[moderation_routes]) } + end + + context "when the user is a super admin" do + let(:user) { build(:user, :super_admin) } + + it { is_expected.to permit_actions(%i[moderation_routes]) } + end end diff --git a/spec/requests/internal/negative_reactions_spec.rb b/spec/requests/internal/privileged_reactions_spec.rb similarity index 85% rename from spec/requests/internal/negative_reactions_spec.rb rename to spec/requests/internal/privileged_reactions_spec.rb index 3c4890d88..38f86ab19 100644 --- a/spec/requests/internal/negative_reactions_spec.rb +++ b/spec/requests/internal/privileged_reactions_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "/internal/negative_reactions", type: :request do +RSpec.describe "/internal/privileged_reactions", type: :request do context "when the user is not an admin" do let(:user) { create(:user) } @@ -10,7 +10,7 @@ RSpec.describe "/internal/negative_reactions", type: :request do it "blocks the request" do expect do - get "/internal/negative_reactions" + get "/internal/privileged_reactions" end.to raise_error(Pundit::NotAuthorizedError) end end @@ -38,13 +38,13 @@ RSpec.describe "/internal/negative_reactions", type: :request do it "does not block the request" do expect do - get "/internal/negative_reactions" + get "/internal/privileged_reactions" end.not_to raise_error end - describe "GETS /internal/negative_reactions" do + describe "GETS /internal/privileged_reactions" do it "renders to appropriate page" do - get "/internal/negative_reactions" + get "/internal/privileged_reactions" expect(response.body).to include(CGI.escapeHTML(moderator.username)). and include(CGI.escapeHTML(user_reaction.reactable.username)). and include(CGI.escapeHTML(comment_reaction.reactable.user.username)). diff --git a/spec/requests/moderations_spec.rb b/spec/requests/moderations_spec.rb index 1c7fc13e0..2c00f861e 100644 --- a/spec/requests/moderations_spec.rb +++ b/spec/requests/moderations_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" RSpec.shared_examples "an elevated privilege required request" do |path| context "when not logged-in" do - it "does not grant acesss", proper_status: true do + it "does not grant access", proper_status: true do get path expect(response).to have_http_status(:not_found) end @@ -27,17 +27,18 @@ RSpec.shared_examples "an elevated privilege required request" do |path| end RSpec.describe "Moderations", type: :request do - let(:user) { create(:user, :trusted) } + let(:trusted_user) { create(:user, :trusted) } let(:article) { create(:article) } let(:comment) { create(:comment, commentable: article) } let(:dev_account) { create(:user) } it_behaves_like "an elevated privilege required request", "/username/random-article/mod" it_behaves_like "an elevated privilege required request", "/username/comment/1/mod" + it_behaves_like "an elevated privilege required request", "/username/random-article/actions_panel" context "when user is trusted" do before do - sign_in user + sign_in trusted_user allow(User).to receive(:dev_account).and_return(dev_account) end @@ -52,7 +53,7 @@ RSpec.describe "Moderations", type: :request do end it "grants access to /mod index" do - create(:rating_vote, article: article, user: user) + create(:rating_vote, article: article, user: trusted_user) get "/mod" expect(response).to have_http_status(:ok) end @@ -69,12 +70,69 @@ RSpec.describe "Moderations", type: :request do expect(response.body).to include("#" + article.tags.first.name.titleize) end - it "returns not found for inapprpriate tags" do + it "returns not found for inappropriate tags" do expect { get "/mod/dsdsdsweweedsdseweww" }.to raise_exception(ActiveRecord::RecordNotFound) end it "renders not_found when an article can't be found" do - expect { get "/#{user.username}/dsdsdsweweedsdseweww/mod/" }.to raise_exception(ActiveRecord::RecordNotFound) + expect { get "/#{trusted_user.username}/dsdsdsweweedsdseweww/mod/" }.to raise_exception(ActiveRecord::RecordNotFound) + end + end + + describe "actions_panel" do + context "when the user is a tag moderator" do + it "shows the option to remove the tag when the article has the tag" do + tag_mod = create(:user, :tag_moderator) + tag_mod.add_role :trusted + tag = tag_mod.roles.find_by(name: "tag_moderator").resource + article = create(:article, tags: tag) + sign_in tag_mod + + get "#{article.path}/actions_panel" + expect(response.body).to include "circle centered-icon adjustment-icon subtract" + end + end + + it "shows the option to add the tag when the article has the tag" do + tag_mod = create(:user, :tag_moderator) + tag_mod.add_role :trusted + article = create(:article, tags: "javascript, cool, beans") + sign_in tag_mod + + get "#{article.path}/actions_panel" + expect(response.body).to include "circle centered-icon adjustment-icon plus" + end + end + + context "when the user is trusted" do + before do + sign_in trusted_user + get "#{article.path}/actions_panel" + end + + it "does not show the adjust tags options" do + expect(response.body).not_to include "other-things-btn adjust-tags" + end + + it "shows the experience level option" do + expect(response.body).to include "other-things-btn set-experience" + end + end + + context "when the user is an admin" do + before do + admin = create(:user, :admin) + sign_in admin + article = create(:article, tags: "javascript, cool, beans") + get "#{article.path}/actions_panel" + end + + it "shows the admin add tag option if the article has room for a tag" do + expect(response.body).to include "admin-add-tag" + end + + it "shows the option to remove tags" do + expect(response.body).to include "circle centered-icon adjustment-icon subtract" end end end