diff options
57 files changed, 812 insertions, 463 deletions
diff --git a/app/assets/javascripts/awards_handler.coffee b/app/assets/javascripts/awards_handler.coffee index bf95e06b4e5..c18c9984c1f 100644 --- a/app/assets/javascripts/awards_handler.coffee +++ b/app/assets/javascripts/awards_handler.coffee @@ -1,63 +1,108 @@ class @AwardsHandler - constructor: (@getEmojisUrl, @postEmojiUrl, @noteableType, @noteableId, @unicodes) -> - $('.js-add-award').on 'click', (event) => - event.stopPropagation() - event.preventDefault() + constructor: -> + @aliases = gl.emoji.emojiAliases() - @showEmojiMenu() + $(document) + .off "click", ".js-add-award" + .on "click", ".js-add-award", (e) => + e.stopPropagation() + e.preventDefault() - $('html').on 'click', (event) -> - if !$(event.target).closest('.emoji-menu').length - if $('.emoji-menu').is(':visible') - $('.emoji-menu').removeClass 'is-visible' + @showEmojiMenu $(e.currentTarget) - $('.awards') - .off 'click' - .on 'click', '.js-emoji-btn', @handleClick + $("html").on 'click', (e) -> + if !$(e.target).closest(".emoji-menu").length + if $(".emoji-menu").is(":visible") + $('.js-add-award.is-active').removeClass 'is-active' + $(".emoji-menu").removeClass "is-visible" - @renderFrequentlyUsedBlock() + $(document) + .off "click", ".js-emoji-btn" + .on "click", ".js-emoji-btn", @handleClick.bind(@) handleClick: (e) -> e.preventDefault() - emoji = $(this) - .find('.icon') - .data 'emoji' + $emojiBtn = $(e.currentTarget) + $addAwardBtn = $('.js-add-award.is-active') + $votesBlock = $($addAwardBtn.closest('.js-award-holder').data('target')) - if emoji is 'thumbsup' and awardsHandler.didUserClickEmoji $(this), 'thumbsdown' - awardsHandler.addAward 'thumbsdown' + if $addAwardBtn.length is 0 + $votesBlock = $emojiBtn.closest('.js-awards-block') + else if $votesBlock.length is 0 + $votesBlock = $addAwardBtn.closest('.js-awards-block') - else if emoji is 'thumbsdown' and awardsHandler.didUserClickEmoji $(this), 'thumbsup' - awardsHandler.addAward 'thumbsup' + @currentVoteBlock = $votesBlock - awardsHandler.addAward emoji + awardUrl = $votesBlock.data 'award-url' + emoji = $emojiBtn + .find(".icon") + .data "emoji" - $(this).trigger 'blur' + if emoji is "thumbsup" and @didUserClickEmoji $emojiBtn, "thumbsdown" + @addAward awardUrl, "thumbsdown" - didUserClickEmoji: (that, emoji) -> - if $(that).siblings("button:has([data-emoji=#{emoji}])").attr('data-original-title') - $(that).siblings("button:has([data-emoji=#{emoji}])").attr('data-original-title').indexOf('me') > -1 + else if emoji is "thumbsdown" and @didUserClickEmoji $emojiBtn, "thumbsup" + @addAward awardUrl, "thumbsup" - showEmojiMenu: -> - if $('.emoji-menu').length - if $('.emoji-menu').is '.is-visible' - $('.emoji-menu').removeClass 'is-visible' - $('#emoji_search').blur() + @addAward awardUrl, emoji + + didUserClickEmoji: (emojiBtn, emoji) -> + if emojiBtn.siblings("button:has([data-emoji=#{emoji}])").attr("data-original-title") + emojiBtn.siblings("button:has([data-emoji=#{emoji}])").attr("data-original-title").indexOf('me') > -1 + + showEmojiMenu: ($addBtn) -> + $menu = $('.emoji-menu') + if $menu.length + $holder = $addBtn.closest('.js-award-holder') + + if $menu.is ".is-visible" + $addBtn.removeClass "is-active" + $menu.removeClass "is-visible" + $("#emoji_search").blur() else - $('.emoji-menu').addClass 'is-visible' - $('#emoji_search').focus() + $(".emoji-menu").addClass "is-visible" + $addBtn.addClass "is-active" + @positionMenu($menu, $addBtn) + + $menu.addClass "is-visible" + $("#emoji_search").focus() else - $('.js-add-award').addClass 'is-loading' - $.get @getEmojisUrl, (response) => - $('.js-add-award').removeClass 'is-loading' - $('.js-award-holder').append response + $addBtn.addClass "is-loading is-active" + $.get $addBtn.data('award-menu-url'), (response) => + $addBtn.removeClass "is-loading" + $('body').append response + + $menu = $(".emoji-menu") + + @positionMenu($menu, $addBtn) + + @renderFrequentlyUsedBlock() setTimeout => - $('.emoji-menu').addClass 'is-visible' - $('#emoji_search').focus() + $menu.addClass "is-visible" + $("#emoji_search").focus() @setupSearch() , 200 - addAward: (emoji) -> - @postEmoji emoji, => + positionMenu: ($menu, $addBtn) -> + position = $addBtn.data('position') + + # The menu could potentially be off-screen or in a hidden overflow element + # So we position the element absolute in the body + css = + top: "#{$addBtn.offset().top + $addBtn.outerHeight()}px" + + if position? and position is 'right' + css.left = "#{($addBtn.offset().left - $menu.outerWidth()) + 20}px" + $menu.addClass "is-aligned-right" + else + css.left = "#{$addBtn.offset().left}px" + $menu.removeClass "is-aligned-right" + + $menu.css(css) + + addAward: (awardUrl, emoji) -> + emoji = @normilizeEmojiName(emoji) + @postEmoji awardUrl, emoji, => @addAwardToEmojiBar(emoji) $('.emoji-menu').removeClass 'is-visible' @@ -65,59 +110,62 @@ class @AwardsHandler addAwardToEmojiBar: (emoji) -> @addEmojiToFrequentlyUsedList(emoji) - if @exist(emoji) - if @isActive(emoji) - @decrementCounter(emoji) + emoji = @normilizeEmojiName(emoji) + $emojiBtn = @findEmojiIcon(emoji).parent() + + if $emojiBtn.length > 0 + if @isActive($emojiBtn) + @decrementCounter($emojiBtn, emoji) else - counter = @findEmojiIcon(emoji).siblings('.js-counter') - counter.text(parseInt(counter.text()) + 1) - counter.parent().addClass('active') - @addMeToAuthorList(emoji) + $counter = $emojiBtn.find('.js-counter') + $counter.text(parseInt($counter.text()) + 1) + $emojiBtn.addClass("active") + @addMeToUserList(emoji) else @createEmoji(emoji) - exist: (emoji) -> - @findEmojiIcon(emoji).length > 0 - - isActive: (emoji) -> - @findEmojiIcon(emoji).parent().hasClass('active') - - decrementCounter: (emoji) -> - counter = @findEmojiIcon(emoji).siblings('.js-counter') - emojiIcon = counter.parent() - if parseInt(counter.text()) > 1 - counter.text(parseInt(counter.text()) - 1) - emojiIcon.removeClass('active') - @removeMeFromAuthorList(emoji) - else if emoji == 'thumbsup' || emoji == 'thumbsdown' - emojiIcon.tooltip('destroy') - counter.text(0) - emojiIcon.removeClass('active') - @removeMeFromAuthorList(emoji) + isActive: ($emojiBtn) -> + $emojiBtn.hasClass("active") + + decrementCounter: ($emojiBtn, emoji) -> + $awardsBlock = $emojiBtn.closest('.js-awards-block') + isntNoteBody = $emojiBtn.closest('.note-body').length is 0 + counter = $('.js-counter', $emojiBtn) + counterNumber = parseInt(counter.text()) + + if counterNumber > 1 + counter.text(counterNumber - 1) + @removeMeFromUserList($emojiBtn, emoji) + else if (emoji == "thumbsup" || emoji == "thumbsdown") && isntNoteBody + $emojiBtn.tooltip("destroy") + counter.text('0') + @removeMeFromUserList($emojiBtn, emoji) else - emojiIcon.tooltip('destroy') - emojiIcon.remove() - - removeMeFromAuthorList: (emoji) -> - awardBlock = @findEmojiIcon(emoji).parent() - authors = awardBlock - .attr('data-original-title') - .split(', ') - authors.splice(authors.indexOf('me'),1) - awardBlock - .closest('.js-emoji-btn') - .attr('data-original-title', authors.join(', ')) - @resetTooltip(awardBlock) - - addMeToAuthorList: (emoji) -> - awardBlock = @findEmojiIcon(emoji).parent() - origTitle = awardBlock.attr('data-original-title').trim() - authors = [] + $emojiBtn.tooltip("destroy") + $emojiBtn.remove() + + $emojiBtn.removeClass("active") + + removeMeFromUserList: ($emojiBtn, emoji) -> + award_block = $emojiBtn + authors = award_block + .attr("data-original-title") + .split(", ") + authors.splice(authors.indexOf("me"), 1) + award_block + .closest(".js-emoji-btn") + .attr("data-original-title", authors.join(", ")) + @resetTooltip(award_block) + + addMeToUserList: (emoji) -> + award_block = @findEmojiIcon(emoji).parent() + origTitle = award_block.attr("data-original-title").trim() + users = [] if origTitle - authors = origTitle.split(', ') - authors.push('me') - awardBlock.attr('data-original-title', authors.join(', ')) - @resetTooltip(awardBlock) + users = origTitle.split(', ') + users.push("me") + award_block.attr("data-original-title", users.join(", ")) + @resetTooltip(award_block) resetTooltip: (award) -> award.tooltip('destroy') @@ -127,24 +175,24 @@ class @AwardsHandler award.tooltip() ), 200 - createEmoji: (emoji) -> emojiCssClass = @resolveNameToCssClass(emoji) - nodes = [] - nodes.push( - "<button class='btn award-control js-emoji-btn has-tooltip active' data-original-title='me'>", - "<div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div>", - "<span class='award-control-text js-counter'>1</span>", - "</button>" - ) - - $(nodes.join("\n")) - .insertBefore('.js-award-holder') - .find('.emoji-icon') - .data('emoji', emoji) + buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> + <div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div> + <span class='award-control-text js-counter'>1</span> + </button>" + + emoji_node = $(buttonHtml) + .insertBefore(".js-awards-block-current .js-award-holder:not(.js-award-action-btn)") + .find(".emoji-icon") + .data("emoji", emoji) $('.award-control').tooltip() + $currentBlock = $('.js-awards-block-current') + if $currentBlock.is('.hidden') + $currentBlock.removeClass 'hidden' + resolveNameToCssClass: (emoji) -> emojiIcon = $(".emoji-menu-content [data-emoji='#{emoji}']") @@ -156,17 +204,13 @@ class @AwardsHandler "emoji-#{unicodeName}" - postEmoji: (emoji, callback) -> - $.post @postEmojiUrl, { note: { - note: ":#{emoji}:" - noteable_type: @noteableType - noteable_id: @noteableId - }},(data) -> + postEmoji: (awardUrl, emoji, callback) -> + $.post awardUrl, { name: emoji }, (data) -> if data.ok callback.call() findEmojiIcon: (emoji) -> - $(".awards > .js-emoji-btn [data-emoji='#{emoji}']") + @currentVoteBlock.find(".js-emoji-btn [data-emoji='#{emoji}']") scrollToAwards: -> $('body, html').animate({ @@ -186,11 +230,10 @@ class @AwardsHandler if $.cookie('frequently_used_emojis') frequentlyUsedEmojis = @getFrequentlyUsedEmojis() - ul = $('<ul>') + ul = $("<ul class='clearfix emoji-menu-list'>") - for emoji in frequentlyUsedEmojis - do (emoji) -> - $(".emoji-menu-content [data-emoji='#{emoji}']").closest('li').clone().appendTo(ul) + for emoji in frequently_used_emojis + $(".emoji-menu-content [data-emoji='#{emoji}']").closest("li").clone().appendTo(ul) $('input.emoji-search').after(ul).after($('<h5>').text('Frequently used')) diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index f91aa3c5ad7..e00ca2984b9 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -23,6 +23,7 @@ class Dispatcher new Issue() shortcut_handler = new ShortcutsIssuable() new ZenMode() + awards_handler = new AwardsHandler() when 'projects:milestones:show', 'groups:milestones:show', 'dashboard:milestones:show' new Milestone() when 'dashboard:todos:index' @@ -53,6 +54,7 @@ class Dispatcher new Diff() shortcut_handler = new ShortcutsIssuable(true) new ZenMode() + awards_handler = new AwardsHandler() when "projects:merge_requests:diffs" new Diff() new ZenMode() diff --git a/app/assets/javascripts/lib/emoji_aliases.js.coffee.erb b/app/assets/javascripts/lib/emoji_aliases.js.coffee.erb new file mode 100644 index 00000000000..e005ab3684e --- /dev/null +++ b/app/assets/javascripts/lib/emoji_aliases.js.coffee.erb @@ -0,0 +1,9 @@ +((w) -> + + w.gl ?= {} + w.gl.emoji ?= {} + + w.gl.emoji.emojiAliases = -> + JSON.parse('<%= Gitlab::AwardEmoji.aliases.to_json %>') + +) window
\ No newline at end of file diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index efb3e8e2198..e8a92b8012d 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -162,13 +162,13 @@ class @Notes renderNote: (note) -> unless note.valid if note.award - flash = new Flash('You have already used this award emoji!', 'alert') + flash = new Flash('You have already awarded this emoji, it has been removed', 'alert') flash.pinTo('.header-content') return if note.award - awardsHandler.addAwardToEmojiBar(note.note) - awardsHandler.scrollToAwards() + awards_handler.addAwardToEmojiBar(note.name) + awards_handler.scrollToAwards() # render note if it not present in loaded list # or skip if rendered diff --git a/app/assets/stylesheets/pages/awards.scss b/app/assets/stylesheets/pages/awards.scss index 37bf38fa65d..07d40f40556 100644 --- a/app/assets/stylesheets/pages/awards.scss +++ b/app/assets/stylesheets/pages/awards.scss @@ -1,6 +1,4 @@ .awards { - line-height: 34px; - .emoji-icon { width: 20px; height: 20px; @@ -9,8 +7,6 @@ .emoji-menu { position: absolute; - top: 100%; - left: 0; margin-top: 3px; z-index: 1000; min-width: 160px; @@ -23,7 +19,12 @@ opacity: 0; transform: scale(.2); transform-origin: 0 -45px; - transition: all .3s cubic-bezier(.87,-.41,.19,1.44); + transition: .3s cubic-bezier(.87,-.41,.19,1.44); + transition-property: transform, opacity; + + &.is-aligned-right { + transform-origin: 100% -45px; + } &.is-visible { pointer-events: all; @@ -107,7 +108,7 @@ } &.is-loading { - .award-control-icon { + .award-control-icon-normal { display: none; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 624c8249f7e..8ad47a50541 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -63,7 +63,8 @@ ul.notes { &.is-editting { .note-header, .note-text, - .edited-text { + .edited-text, + .note-awards { display: none; } @@ -73,8 +74,6 @@ ul.notes { } .note-body { - overflow: auto; - .note-text { overflow: auto; word-wrap: break-word; @@ -324,6 +323,42 @@ ul.notes { } } +.note-award-control { + display: block; + + &:hover, + &:focus { + text-decoration: none; + } + + .award-control-icon-loading { + display: none; + } + + &.is-loading { + .award-control-icon-normal { + display: none; + } + + .award-control-icon-loading { + display: block; + } + } +} + +.note-awards { + .awards { + padding-top: 10px; + } + + .award-control { + padding-top: 2px; + padding-bottom: 2px; + color: #8f8f8f; + font-size: 13px; + } +} + .disabled-comment { margin-left: -$gl-padding-top; margin-right: -$gl-padding-top; diff --git a/app/controllers/concerns/toggle_award_emoji.rb b/app/controllers/concerns/toggle_award_emoji.rb new file mode 100644 index 00000000000..9cd522d1c30 --- /dev/null +++ b/app/controllers/concerns/toggle_award_emoji.rb @@ -0,0 +1,20 @@ +module ToggleAwardEmoji + extend ActiveSupport::Concern + + included do + before_action :authenticate_user!, only: [:toggle_award_emoji] + end + + def toggle_award_emoji + name = params.require(:name) + awardable.toggle_award_emoji(name, current_user) + + render json: { ok: true } + end + + private + + def awardable + raise NotImplementedError + end +end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 016f5dd0005..5f3745d94b9 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,6 +1,7 @@ class Projects::IssuesController < Projects::ApplicationController include ToggleSubscriptionAction include IssuableActions + include ToggleAwardEmoji before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, @@ -62,7 +63,7 @@ class Projects::IssuesController < Projects::ApplicationController def show @note = @project.notes.new(noteable: @issue) - @notes = @issue.notes.nonawards.with_associations.fresh + @notes = @issue.notes.with_associations.fresh @noteable = @issue respond_to do |format| @@ -169,6 +170,7 @@ class Projects::IssuesController < Projects::ApplicationController end alias_method :subscribable_resource, :issue alias_method :issuable, :issue + alias_method :awardable, :issue def authorize_read_issue! return render_404 unless can?(current_user, :read_issue, @issue) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 9c147b3689e..164c035e8bc 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -2,6 +2,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController include ToggleSubscriptionAction include DiffHelper include IssuableActions + include ToggleAwardEmoji before_action :module_enabled before_action :merge_request, only: [ @@ -195,7 +196,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController if params[:merge_when_build_succeeds].present? && @merge_request.ci_commit && @merge_request.ci_commit.active? MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params) - .execute(@merge_request) + .execute(@merge_request) @status = :merge_when_build_succeeds else MergeWorker.perform_async(@merge_request.id, current_user.id, params) @@ -264,6 +265,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end alias_method :subscribable_resource, :merge_request alias_method :issuable, :merge_request + alias_method :awardable, :merge_request def closes_issues @closes_issues ||= @merge_request.closes_issues @@ -299,7 +301,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController def define_show_vars # Build a note object for comment form @note = @project.notes.new(noteable: @merge_request) - @notes = @merge_request.mr_and_commit_notes.nonawards.inc_author.fresh + @notes = @merge_request.mr_and_commit_notes.inc_author.fresh @discussions = Note.discussions_from_notes(@notes) @noteable = @merge_request diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 707a0d0e5c6..eb5137fe999 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -3,7 +3,7 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_read_note! before_action :authorize_create_note!, only: [:create] before_action :authorize_admin_note!, only: [:update, :destroy] - before_action :find_current_user_notes, except: [:destroy, :delete_attachment, :award_toggle] + before_action :find_current_user_notes, only: [:index] def index current_fetched_at = Time.now.to_i @@ -22,8 +22,10 @@ class Projects::NotesController < Projects::ApplicationController def create @note = Notes::CreateService.new(project, current_user, note_params).execute + @note = @note.is_a?(AwardEmoji) ? @note.to_note_json : note_json(@note) + respond_to do |format| - format.json { render json: note_json(@note) } + format.json { render json: @note } format.html { redirect_back_or_default } end end @@ -56,30 +58,6 @@ class Projects::NotesController < Projects::ApplicationController end end - def award_toggle - noteable = if note_params[:noteable_type] == "issue" - project.issues.find(note_params[:noteable_id]) - else - project.merge_requests.find(note_params[:noteable_id]) - end - - data = { - author: current_user, - is_award: true, - note: note_params[:note].delete(":") - } - - note = noteable.notes.find_by(data) - - if note - note.destroy - else - Notes::CreateService.new(project, current_user, note_params).execute - end - - render json: { ok: true } - end - private def note @@ -137,7 +115,7 @@ class Projects::NotesController < Projects::ApplicationController id: note.id, discussion_id: note.discussion_id, html: note_to_html(note), - award: note.is_award, + award: false, note: note.note, discussion_html: note_to_discussion_html(note), discussion_with_diff_html: note_to_discussion_with_diff_html(note) @@ -145,7 +123,7 @@ class Projects::NotesController < Projects::ApplicationController else { valid: false, - award: note.is_award, + award: false, errors: note.errors } end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 3768efe142a..85a987c2cb2 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -145,7 +145,7 @@ class ProjectsController < Projects::ApplicationController participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) @suggestions = { - emojis: AwardEmoji.urls, + emojis: Gitlab::AwardEmoji.urls, issues: autocomplete.issues, mergerequests: autocomplete.merge_requests, members: participants diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index fa4c635f55c..ab252821b52 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -12,9 +12,9 @@ class NotesFinder when "commit" project.notes.for_commit_id(target_id).not_inline when "issue" - project.issues.find(target_id).notes.nonawards.inc_author + project.issues.find(target_id).notes.inc_author when "merge_request" - project.merge_requests.find(target_id).mr_and_commit_notes.nonawards.inc_author + project.merge_requests.find(target_id).mr_and_commit_notes.inc_author when "snippet", "project_snippet" project.snippets.find(target_id).notes else diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 198d39455d7..0ea712c8266 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -162,16 +162,14 @@ module IssuesHelper end end - def emoji_author_list(notes, current_user) - list = notes.map do |note| - note.author == current_user ? "me" : note.author.name - end - - list.join(", ") + def award_user_list(awards, current_user) + awards.map do |award| + award.user == current_user ? 'me' : award.user.name + end.join(', ') end - def note_active_class(notes, current_user) - if current_user && notes.pluck(:author_id).include?(current_user.id) + def award_active_class(awards, current_user) + if current_user && awards.find { |a| a.user_id == current_user.id } "active" else "" diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb new file mode 100644 index 00000000000..44a9b55a8a6 --- /dev/null +++ b/app/models/award_emoji.rb @@ -0,0 +1,35 @@ +class AwardEmoji < ActiveRecord::Base + DOWNVOTE_NAME = "thumbsdown".freeze + UPVOTE_NAME = "thumbsup".freeze + + include Participable + + belongs_to :awardable, polymorphic: true + belongs_to :user + + validates :awardable, :user, presence: true + validates :name, presence: true, inclusion: { in: Emoji.emojis_names } + validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] } + + participant :user + + scope :downvotes, -> { where(name: DOWNVOTE_NAME) } + scope :upvotes, -> { where(name: UPVOTE_NAME) } + + def downvote? + self.name == DOWNVOTE_NAME + end + + def upvote? + self.name == UPVOTE_NAME + end + + def to_note_json + { + valid: valid?, + award: true, + id: id, + name: name + } + end +end diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb new file mode 100644 index 00000000000..b4e3e9eb3dd --- /dev/null +++ b/app/models/concerns/awardable.rb @@ -0,0 +1,81 @@ +module Awardable + extend ActiveSupport::Concern + + included do + has_many :award_emoji, as: :awardable, dependent: :destroy + + if self < Participable + participant :award_emoji + end + end + + module ClassMethods + def order_upvotes_desc + order_votes_desc(AwardEmoji::UPVOTE_NAME) + end + + def order_downvotes_desc + order_votes_desc(AwardEmoji::DOWNVOTE_NAME) + end + + def order_votes_desc(emoji_name) + awardable_table = self.arel_table + awards_table = AwardEmoji.arel_table + + join_clause = awardable_table.join(awards_table, Arel::Nodes::OuterJoin).on( + awards_table[:awardable_id].eq(awardable_table[:id]).and( + awards_table[:awardable_type].eq(self.name).and( + awards_table[:name].eq(emoji_name) + ) + ) + ).join_sources + + joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) DESC") + end + end + + def grouped_awards(with_thumbs = true) + awards = award_emoji.group_by(&:name) + + if with_thumbs + awards[AwardEmoji::UPVOTE_NAME] ||= AwardEmoji.none + awards[AwardEmoji::DOWNVOTE_NAME] ||= AwardEmoji.none + end + + awards + end + + def downvotes + award_emoji.where(name: AwardEmoji::DOWNVOTE_NAME).count + end + + def upvotes + award_emoji.where(name: AwardEmoji::UPVOTE_NAME).count + end + + def emoji_awardable? + true + end + + def awarded_emoji?(emoji_name, current_user) + award_emoji.where(name: emoji_name, user: current_user).exists? + end + + def create_award_emoji(name, current_user) + return unless emoji_awardable? + + award_emoji.create(name: name, user: current_user) + end + + def remove_award_emoji(name, current_user) + award_emoji.where(name: name, user: current_user).destroy_all + end + + def toggle_award_emoji(emoji_name, current_user) + if awarded_emoji?(emoji_name, current_user) + remove_award_emoji(emoji_name, current_user) + else + create_award_emoji(emoji_name, current_user) + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 7a3db742030..9b77b88ca80 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -10,6 +10,7 @@ module Issuable include Mentionable include Subscribable include StripAttribute + include Awardable included do belongs_to :author, class_name: "User" @@ -99,37 +100,6 @@ module Issuable order_by(method) end end - - def order_downvotes_desc - order_votes_desc('thumbsdown') - end - - def order_upvotes_desc - order_votes_desc('thumbsup') - end - - def order_votes_desc(award_emoji_name) - issuable_table = self.arel_table - note_table = Note.arel_table - - join_clause = issuable_table.join(note_table, Arel::Nodes::OuterJoin).on( - note_table[:noteable_id].eq(issuable_table[:id]).and( - note_table[:noteable_type].eq(self.name).and( - note_table[:is_award].eq(true).and(note_table[:note].eq(award_emoji_name)) - ) - ) - ).join_sources - - joins(join_clause).group(issuable_table[:id]).reorder("COUNT(notes.id) DESC") - end - - def with_label(title) - if title.is_a?(Array) && title.count > 1 - joins(:labels).where(labels: { title: title }).group('issues.id').having("count(distinct labels.title) = #{title.count}") - else - joins(:labels).where(labels: { title: title }) - end - end end def today? @@ -152,18 +122,6 @@ module Issuable opened? || reopened? end - def downvotes - notes.awards.where(note: "thumbsdown").count - end - - def upvotes - notes.awards.where(note: "thumbsup").count - end - - def user_notes_count - notes.user.count - end - def subscribed_without_subscriptions?(user) participants(user).include?(user) end diff --git a/app/models/note.rb b/app/models/note.rb index f26aa1bf63f..0b038edb47b 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -21,12 +21,9 @@ class Note < ActiveRecord::Base delegate :name, to: :project, prefix: true delegate :name, :email, to: :author, prefix: true - before_validation :set_award! before_validation :clear_blank_line_code! validates :note, :project, presence: true - validates :note, uniqueness: { scope: [:author, :noteable_type, :noteable_id] }, if: ->(n) { n.is_award } - validates :note, inclusion: { in: Emoji.emojis_names }, if: ->(n) { n.is_award } validates :line_code, line_code: true, allow_blank: true # Attachments are deprecated and are handled by Markdown uploader validates :attachment, file_size: { maximum: :max_attachment_size } @@ -38,8 +35,6 @@ class Note < ActiveRecord::Base mount_uploader :attachment, AttachmentUploader # Scopes - scope :awards, ->{ where(is_award: true) } - scope :nonawards, ->{ where(is_award: false) } scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) } scope :inline, ->{ where("line_code IS NOT NULL") } scope :not_inline, ->{ where(line_code: nil) } @@ -97,19 +92,6 @@ class Note < ActiveRecord::Base where(table[:note].matches(pattern)) end - - def grouped_awards - notes = {} - - awards.select(:note).distinct.map do |note| - notes[note.note] = where(note: note.note) - end - - notes["thumbsup"] ||= Note.none - notes["thumbsdown"] ||= Note.none - - notes - end end def cross_reference? @@ -325,37 +307,25 @@ class Note < ActiveRecord::Base Event.reset_event_cache_for(self) end - def downvote? - is_award && note == "thumbsdown" - end - - def upvote? - is_award && note == "thumbsup" + def system? + read_attribute(:system) end def editable? - !system? && !is_award + !system? end def cross_reference_not_visible_for?(user) cross_reference? && referenced_mentionables(user).empty? end - # Checks if note is an award added as a comment - # - # If note is an award, this method sets is_award to true - # and changes content of the note to award name. - # - # Method is executed as a before_validation callback. - # - def set_award! - return unless awards_supported? && contains_emoji_only? - - self.is_award = true - self.note = award_emoji_name + def award_emoji? + award_emoji_supported? && contains_emoji_only? end - private + def create_award_emoji + self.noteable.award_emoji(award_emoji_name, author) + end def clear_blank_line_code! self.line_code = nil if self.line_code.blank? @@ -367,8 +337,8 @@ class Note < ActiveRecord::Base diffs.find { |d| d.new_path == self.diff.new_path } end - def awards_supported? - (for_issue? || for_merge_request?) && !for_diff_line? + def award_emoji_supported? + noteable.is_a?(Awardable) && !for_diff_line? end def contains_emoji_only? @@ -377,6 +347,6 @@ class Note < ActiveRecord::Base def award_emoji_name original_name = note.match(Banzai::Filter::EmojiFilter.emoji_pattern)[1] - AwardEmoji.normilize_emoji_name(original_name) + Gitlab::AwardEmoji.normilize_emoji_name(original_name) end end diff --git a/app/models/user.rb b/app/models/user.rb index 1e4814641d1..37179c10788 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -79,6 +79,7 @@ class User < ActiveRecord::Base has_many :builds, dependent: :nullify, class_name: 'Ci::Build' has_many :todos, dependent: :destroy has_many :notification_settings, dependent: :destroy + has_many :award_emoji, as: :awardable, dependent: :destroy # # Validations diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 01586994813..da2a774b70d 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -5,6 +5,11 @@ module Notes note.author = current_user note.system = false + if note.award_emoji? + return ToggleAwardEmojiService.new(project, current_user, params). + execute(note.noteable, note.note) + end + return unless valid_project?(note) if note.save diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index e818f58d13c..c1bf46bdfb3 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -8,7 +8,7 @@ module Notes def execute # Skip system notes, like status changes and cross-references and awards - unless @note.system || @note.is_award + unless @note.system EventCreateService.new.leave_note(@note, @note.author) @note.create_cross_references! execute_note_hooks diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 42ec1ac9e1a..703636658b7 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -131,7 +131,6 @@ class NotificationService # ignore gitlab service messages return true if note.note.start_with?('Status changed to closed') return true if note.cross_reference? && note.system == true - return true if note.is_award target = note.noteable diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 42c5bca90fd..da1b77c0f9e 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -98,6 +98,14 @@ class TodoService handle_note(note, current_user) end + # When an emoji is awarded we should: + # + # * mark all pending todos related to the awardable for the current user as done + # + def new_award_emoji(awardable, current_user) + mark_pending_todos_as_done(awardable, current_user) + end + # When marking pending todos as done we should: # # * mark all pending todos related to the target for the current user as done diff --git a/app/services/toggle_award_emoji_service.rb b/app/services/toggle_award_emoji_service.rb new file mode 100644 index 00000000000..b77b4e79bf2 --- /dev/null +++ b/app/services/toggle_award_emoji_service.rb @@ -0,0 +1,21 @@ +require_relative 'base_service' + +class ToggleAwardEmojiService < BaseService + # For an award emoji being posted we should: + # - Mark the TODO as done for this issuable (skip on snippets) + # - Save the award emoji + def execute(awardable, emoji) + todo_service.new_award_emoji(awardable, current_user) + + # Needed if its posted as a note containing only :+1: + emoji = award_emoji_name(emoji) if emoji.start_with? ':' + awardable.toggle_award_emoji(emoji, current_user) + end + + private + + def award_emoji_name(emoji) + original_name = emoji.match(Banzai::Filter::EmojiFilter.emoji_pattern)[1] + Gitlab::AwardEmoji.normalize_emoji_name(original_name) + end +end diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml new file mode 100644 index 00000000000..b57c9afcbd2 --- /dev/null +++ b/app/views/award_emoji/_awards_block.html.haml @@ -0,0 +1,15 @@ +- grouped_emojis = awardable.grouped_awards(inline) +.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.size == 0), data: { award_url: url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable]) } } + - awards_sort(grouped_emojis).each do |emoji, awards| + %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button", class: (award_active_class(awards, current_user)),data: { placement: "bottom", original_title: award_user_list(awards, current_user)} } + = emoji_icon(emoji) + %span.award-control-text.js-counter + = awards.count + + - if current_user + .award-menu-holder.js-award-holder + %button.btn.award-control.js-add-award{ type: "button", data: { award_menu_url: emojis_path } } + = icon('smile-o', {class: "award-control-icon award-control-icon-normal"}) + = icon('spinner spin', {class: "award-control-icon award-control-icon-loading"}) + %span.award-control-text + Add diff --git a/app/views/emojis/index.html.haml b/app/views/emojis/index.html.haml index 3443a8e2307..97401a2e618 100644 --- a/app/views/emojis/index.html.haml +++ b/app/views/emojis/index.html.haml @@ -1,9 +1,9 @@ .emoji-menu .emoji-menu-content = text_field_tag :emoji_search, "", class: "emoji-search search-input form-control" - - AwardEmoji.emoji_by_category.each do |category, emojis| + - Gitlab::AwardEmoji.emoji_by_category.each do |category, emojis| %h5.emoji-menu-title - = AwardEmoji::CATEGORIES[category] + = Gitlab::AwardEmoji::CATEGORIES[category] %ul.clearfix.emoji-menu-list - emojis.each do |emoji| %li.pull-left.text-center.emoji-menu-list-item diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 5cf70ea3bb7..57ad2ec1852 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -27,11 +27,17 @@ = icon('thumbs-down') = downvotes - - note_count = issue.notes.user.nonawards.count - %li - = link_to issue_path(issue, anchor: 'notes'), class: ('issue-no-comments' if note_count.zero?) do - = icon('comments') - = note_count + - note_count = issue.notes.user.count + - if note_count > 0 + %li + = link_to issue_path(issue) + "#notes" do + = icon('comments') + = note_count + - else + %li + = link_to issue_path(issue) + "#notes", class: "issue-no-comments" do + = icon('comments') + = note_count .issue-info #{issue.to_reference} · diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index bde80bbb54b..6623028c02e 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -69,9 +69,9 @@ #related-branches{ data: { url: related_branches_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. - .content-block.content-block-small - = render 'new_branch' - = render 'votes/votes_block', votable: @issue + .content-block.content-block-small + = render 'new_branch' + = render 'award_emoji/awards_block', awardable: @issue, inline: true %section.issuable-discussion = render 'projects/issues/discussion' diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 73c6a95f5ca..391193eed6c 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -35,11 +35,17 @@ = icon('thumbs-down') = downvotes - - note_count = merge_request.mr_and_commit_notes.user.nonawards.count - %li - = link_to merge_request_path(merge_request, anchor: 'notes'), class: ('merge-request-no-comments' if note_count.zero?) do - = icon('comments') - = note_count + - note_count = merge_request.mr_and_commit_notes.user.count + - if note_count > 0 + %li + = link_to merge_request_path(merge_request) + "#notes" do + = icon('comments') + = note_count + - else + %li + = link_to merge_request_path(merge_request) + "#notes", class: "merge-request-no-comments" do + = icon('comments') + = note_count .merge-request-info #{merge_request.to_reference} · diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 290753d57c6..23525318400 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -50,7 +50,7 @@ %li.notes-tab = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#notes', action: 'notes', toggle: 'tab'} do Discussion - %span.badge= @merge_request.mr_and_commit_notes.user.nonawards.count + %span.badge= @merge_request.mr_and_commit_notes.user.count %li.commits-tab = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do Commits @@ -68,7 +68,7 @@ .tab-content #notes.notes.tab-pane.voting_notes .content-block.content-block-small.oneline-block - = render 'votes/votes_block', votable: @merge_request + = render 'award_emoji/awards_block', awardable: @merge_request, inline: true .row %section.col-md-12 diff --git a/app/views/votes/_votes_block.html.haml b/app/views/votes/_votes_block.html.haml index 4beb8746444..ab8bb949862 100644 --- a/app/views/votes/_votes_block.html.haml +++ b/app/views/votes/_votes_block.html.haml @@ -1,9 +1,9 @@ -.awards.votes-block - - awards_sort(votable.notes.awards.grouped_awards).each do |emoji, notes| - %button.btn.award-control.js-emoji-btn.has-tooltip{class: (note_active_class(notes, current_user)), data: {placement: "top", original_title: emoji_author_list(notes, current_user)}} +.awards.votes-block{data: { toggle_url: url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable]) }} + - awards_sort(awardable.grouped_awards).each do |emoji, awards| + %button.btn.award-control.js-emoji-btn.has-tooltip{class: (note_active_class(awards, current_user)), data: {placement: "top", original_title: emoji_author_list(awards, current_user)}} = emoji_icon(emoji, sprite: false) %span.award-control-text.js-counter - = notes.count + = awards.count - if current_user %div.award-menu-holder.js-award-holder diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 9e8b0131f8f..3d1a41a4652 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -8,3 +8,7 @@ # inflect.irregular 'person', 'people' # inflect.uncountable %w( fish sheep ) # end +# +ActiveSupport::Inflector.inflections do |inflect| + inflect.uncountable %w(award_emoji) +end diff --git a/config/routes.rb b/config/routes.rb index dafecc94648..8deb224cde9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -643,6 +643,7 @@ Rails.application.routes.draw do post :cancel_merge_when_build_succeeds get :ci_status post :toggle_subscription + post :toggle_award_emoji post :remove_wip end @@ -708,6 +709,7 @@ Rails.application.routes.draw do resources :issues, constraints: { id: /\d+/ } do member do post :toggle_subscription + post :toggle_award_emoji get :referenced_merge_requests get :related_branches get :can_create_branch @@ -737,10 +739,7 @@ Rails.application.routes.draw do resources :notes, only: [:index, :create, :destroy, :update], constraints: { id: /\d+/ } do member do delete :delete_attachment - end - - collection do - post :award_toggle + post :toggle_award_emoji end end diff --git a/db/migrate/20160416180807_add_award_emoji.rb b/db/migrate/20160416180807_add_award_emoji.rb new file mode 100644 index 00000000000..3177b86a133 --- /dev/null +++ b/db/migrate/20160416180807_add_award_emoji.rb @@ -0,0 +1,15 @@ +class AddAwardEmoji < ActiveRecord::Migration + def change + create_table :award_emoji do |t| + t.string :name + t.references :user + t.references :awardable, polymorphic: true + + t.timestamps + end + + add_index :award_emoji, :user_id + add_index :award_emoji, :awardable_type + add_index :award_emoji, :awardable_id + end +end diff --git a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb new file mode 100644 index 00000000000..76f4a3aa6ae --- /dev/null +++ b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb @@ -0,0 +1,17 @@ +class ConvertAwardNoteToEmojiAward < ActiveRecord::Migration + def change + def up + execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" + end + + def down + execute <<-SQL + INSERT INTO notes (noteable_type, noteable_id, author_id, note, created_at, updated_at, is_award) + (SELECT awardable_type, awardable_id, user_id, name, created_at, updated_at, TRUE + FROM award_emoji + WHERE awardable_type IN ('Issue', 'MergeRequest') + ) + SQL + end + end +end diff --git a/db/migrate/20160416190505_remove_note_is_award.rb b/db/migrate/20160416190505_remove_note_is_award.rb new file mode 100644 index 00000000000..da16372a297 --- /dev/null +++ b/db/migrate/20160416190505_remove_note_is_award.rb @@ -0,0 +1,5 @@ +class RemoveNoteIsAward < ActiveRecord::Migration + def change + remove_column :notes, :is_award, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 71d953afe30..6db1c8acfa2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -96,6 +96,19 @@ ActiveRecord::Schema.define(version: 20160508194200) do add_index "audit_events", ["entity_id", "entity_type"], name: "index_audit_events_on_entity_id_and_entity_type", using: :btree add_index "audit_events", ["type"], name: "index_audit_events_on_type", using: :btree + create_table "award_emoji", force: :cascade do |t| + t.string "name" + t.integer "user_id" + t.integer "awardable_id" + t.string "awardable_type" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "award_emoji", ["awardable_id"], name: "index_award_emoji_on_awardable_id", using: :btree + add_index "award_emoji", ["awardable_type"], name: "index_award_emoji_on_awardable_type", using: :btree + add_index "award_emoji", ["user_id"], name: "index_award_emoji_on_user_id", using: :btree + create_table "broadcast_messages", force: :cascade do |t| t.text "message", null: false t.datetime "starts_at" @@ -633,14 +646,12 @@ ActiveRecord::Schema.define(version: 20160508194200) do t.boolean "system", default: false, null: false t.text "st_diff" t.integer "updated_by_id" - t.boolean "is_award", default: false, null: false end add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree add_index "notes", ["commit_id"], name: "index_notes_on_commit_id", using: :btree add_index "notes", ["created_at", "id"], name: "index_notes_on_created_at_and_id", using: :btree add_index "notes", ["created_at"], name: "index_notes_on_created_at", using: :btree - add_index "notes", ["is_award"], name: "index_notes_on_is_award", using: :btree add_index "notes", ["line_code"], name: "index_notes_on_line_code", using: :btree add_index "notes", ["note"], name: "index_notes_on_note_trigram", using: :gin, opclasses: {"note"=>"gin_trgm_ops"} add_index "notes", ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type", using: :btree diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index fc12843ea5c..78ddaee8771 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -191,15 +191,15 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'issue "Release 0.4" have 2 upvotes and 1 downvote' do - issue = Issue.find_by(title: 'Release 0.4') - create_list(:upvote_note, 2, project: project, noteable: issue) - create(:downvote_note, project: project, noteable: issue) + awardable = Issue.find_by(title: 'Release 0.4') + create_list(:upvote, 2, project: project, awardable: awardable) + create(:downvote, project: project, awardable: awardable) end step 'issue "Tweet control" have 1 upvote and 2 downvotes' do issue = Issue.find_by(title: 'Tweet control') - create(:upvote_note, project: project, noteable: issue) - create_list(:downvote_note, 2, project: project, noteable: issue) + create(:upvote, project: project, noteable: issue) + create_list(:downvote, 2, project: project, noteable: issue) end step 'The list should be sorted by "Least popular"' do diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 3b1a00f628a..6a9a079592c 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -179,14 +179,14 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'merge request "Bug NS-04" have 2 upvotes and 1 downvote' do merge_request = MergeRequest.find_by(title: 'Bug NS-04') - create_list(:upvote_note, 2, project: project, noteable: merge_request) - create(:downvote_note, project: project, noteable: merge_request) + create_list(:upvote, 2, project: project, awardable: merge_request) + create(:downvote, project: project, awardable: merge_request) end step 'merge request "Bug NS-06" have 1 upvote and 2 downvotes' do - merge_request = MergeRequest.find_by(title: 'Bug NS-06') - create(:upvote_note, project: project, noteable: merge_request) - create_list(:downvote_note, 2, project: project, noteable: merge_request) + awardable = MergeRequest.find_by(title: 'Bug NS-06') + create(:upvote, project: project, awardable: awardable) + create_list(:downvote, 2, project: project, awardable: awardable) end step 'The list should be sorted by "Least popular"' do diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 2870a6a40ef..f91ca9d5805 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -170,6 +170,7 @@ module API expose :label_names, as: :labels expose :milestone, using: Entities::Milestone expose :assignee, :author, using: Entities::UserBasic + expose :subscribed do |issue, options| issue.subscribed?(options[:current_user]) end @@ -178,7 +179,7 @@ module API class MergeRequest < ProjectEntity expose :target_branch, :source_branch - expose :upvotes, :downvotes + expose :upvotes, :downvotes expose :author, :assignee, using: Entities::UserBasic expose :source_project_id, :target_project_id expose :label_names, as: :labels @@ -216,8 +217,8 @@ module API expose :system?, as: :system expose :noteable_id, :noteable_type # upvote? and downvote? are deprecated, always return false - expose :upvote?, as: :upvote - expose :downvote?, as: :downvote + expose(:upvote?) { |note| false } + expose(:downvote?) { |note| false } end class MRNote < Grape::Entity diff --git a/lib/award_emoji.rb b/lib/award_emoji.rb deleted file mode 100644 index b1aecc2e671..00000000000 --- a/lib/award_emoji.rb +++ /dev/null @@ -1,84 +0,0 @@ -class AwardEmoji - CATEGORIES = { - other: "Other", - objects: "Objects", - places: "Places", - travel_places: "Travel", - emoticons: "Emoticons", - objects_symbols: "Symbols", - nature: "Nature", - celebration: "Celebration", - people: "People", - activity: "Activity", - flags: "Flags", - food_drink: "Food" - }.with_indifferent_access - - CATEGORY_ALIASES = { - symbols: "objects_symbols", - foods: "food_drink", - travel: "travel_places" - }.with_indifferent_access - - def self.normilize_emoji_name(name) - aliases[name] || name - end - - def self.emoji_by_category - unless @emoji_by_category - @emoji_by_category = Hash.new { |h, key| h[key] = [] } - - emojis.each do |emoji_name, data| - data["name"] = emoji_name - - # Skip Fitzpatrick(tone) modifiers - next if data["category"] == "modifier" - - category = CATEGORY_ALIASES[data["category"]] || data["category"] - - @emoji_by_category[category] << data - end - - @emoji_by_category = @emoji_by_category.sort.to_h - end - - @emoji_by_category - end - - def self.emojis - @emojis ||= begin - json_path = File.join(Rails.root, 'fixtures', 'emojis', 'index.json' ) - JSON.parse(File.read(json_path)) - end - end - - def self.unicode - @unicode ||= emojis.map {|key, value| { key => emojis[key]["unicode"] } }.inject(:merge!) - end - - def self.aliases - @aliases ||= begin - json_path = File.join(Rails.root, 'fixtures', 'emojis', 'aliases.json' ) - JSON.parse(File.read(json_path)) - end - end - - # Returns an Array of Emoji names and their asset URLs. - def self.urls - @urls ||= begin - path = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json') - prefix = Gitlab::Application.config.assets.prefix - digest = Gitlab::Application.config.assets.digest - - JSON.parse(File.read(path)).map do |hash| - if digest - fname = "#{hash['unicode']}-#{hash['digest']}" - else - fname = hash['unicode'] - end - - { name: hash['name'], path: "#{prefix}/#{fname}.png" } - end - end - end -end diff --git a/lib/gitlab/award_emoji.rb b/lib/gitlab/award_emoji.rb new file mode 100644 index 00000000000..0ae220a86bc --- /dev/null +++ b/lib/gitlab/award_emoji.rb @@ -0,0 +1,82 @@ +module Gitlab + class AwardEmoji + CATEGORIES = { + other: "Other", + objects: "Objects", + places: "Places", + travel_places: "Travel", + emoticons: "Emoticons", + objects_symbols: "Symbols", + nature: "Nature", + celebration: "Celebration", + people: "People", + activity: "Activity", + flags: "Flags", + food_drink: "Food" + }.with_indifferent_access + + CATEGORY_ALIASES = { + symbols: "objects_symbols", + foods: "food_drink", + travel: "travel_places" + }.with_indifferent_access + + def self.normalize_emoji_name(name) + aliases[name] || name + end + + def self.emoji_by_category + unless @emoji_by_category + @emoji_by_category = Hash.new { |h, key| h[key] = [] } + + emojis.each do |emoji_name, data| + data["name"] = emoji_name + + # Skip Fitzpatrick(tone) modifiers + next if data["category"] == "modifier" + + category = CATEGORY_ALIASES[data["category"]] || data["category"] + + @emoji_by_category[category] << data + end + + @emoji_by_category = @emoji_by_category.sort.to_h + end + + @emoji_by_category + end + + def self.emojis + @emojis ||= begin + json_path = File.join(Rails.root, 'fixtures', 'emojis', 'index.json' ) + JSON.parse(File.read(json_path)) + end + end + + def self.aliases + @aliases ||= begin + json_path = File.join(Rails.root, 'fixtures', 'emojis', 'aliases.json' ) + JSON.parse(File.read(json_path)) + end + end + + # Returns an Array of Emoji names and their asset URLs. + def self.urls + @urls ||= begin + path = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json') + prefix = Gitlab::Application.config.assets.prefix + digest = Gitlab::Application.config.assets.digest + + JSON.parse(File.read(path)).map do |hash| + if digest + fname = "#{hash['unicode']}-#{hash['digest']}" + else + fname = hash['unicode'] + end + + { name: hash['name'], path: "#{prefix}/#{fname}.png" } + end + end + end + end +end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 465531b2b36..82b25702172 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -31,9 +31,9 @@ describe GroupsController do let(:issue_2) { create(:issue, project: project) } before do - create_list(:upvote_note, 3, project: project, noteable: issue_2) - create_list(:upvote_note, 2, project: project, noteable: issue_1) - create_list(:downvote_note, 2, project: project, noteable: issue_2) + create_list(:award_emoji, 3, awardable: issue_2) + create_list(:award_emoji, 2, awardable: issue_1) + create_list(:award_emoji, 2, awardable: issue_2, name: "thumbsdown") sign_in(user) end @@ -56,9 +56,9 @@ describe GroupsController do let(:merge_request_2) { create(:merge_request, :simple, source_project: project) } before do - create_list(:upvote_note, 3, project: project, noteable: merge_request_2) - create_list(:upvote_note, 2, project: project, noteable: merge_request_1) - create_list(:downvote_note, 2, project: project, noteable: merge_request_2) + create_list(:award_emoji, 3, awardable: merge_request_2) + create_list(:award_emoji, 2, awardable: merge_request_1) + create_list(:award_emoji, 2, awardable: merge_request_2, name: "thumbsdown") sign_in(user) end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 2b2ad3b9412..30d296fdad0 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -250,4 +250,18 @@ describe Projects::IssuesController do end end end + + describe 'POST #toggle_award_emoji' do + before do + sign_in(user) + project.team << [user, :developer] + end + + it "yields status code 200" do + post(:toggle_award_emoji, namespace_id: project.namespace.path, + project_id: project.path, id: issue.iid, name: "thumbsup") + + expect(response.status).to eq(200) + end + end end diff --git a/spec/factories/award_emoji.rb b/spec/factories/award_emoji.rb new file mode 100644 index 00000000000..b09f8b0bc74 --- /dev/null +++ b/spec/factories/award_emoji.rb @@ -0,0 +1,18 @@ +FactoryGirl.define do + factory :award_emoji do + name "thumbsup" + user + awardable factory: :issue + + trait :thumbs_up + trait :upvote + + trait :thumbs_down do + name "thumbsdown" + end + + trait :downvote do + name "thumbsdown" + end + end +end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 840b13196a6..e65204cbe7b 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -15,8 +15,6 @@ FactoryGirl.define do factory :note_on_merge_request_diff, traits: [:on_merge_request, :on_diff] factory :note_on_project_snippet, traits: [:on_project_snippet] factory :system_note, traits: [:system] - factory :downvote_note, traits: [:award, :downvote] - factory :upvote_note, traits: [:award, :upvote] trait :on_commit do project @@ -48,10 +46,6 @@ FactoryGirl.define do system true end - trait :award do - is_award true - end - trait :downvote do note "thumbsdown" end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index d5755c293c5..dfb554c05ca 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -67,7 +67,7 @@ describe 'Issues', feature: true do describe 'Issue info' do it 'excludes award_emoji from comment count' do issue = create(:issue, author: @user, assignee: @user, project: project, title: 'foobar') - create(:upvote_note, noteable: issue) + create(:award_emoji, awardable: issue) visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id) diff --git a/spec/features/notes_on_merge_requests_spec.rb b/spec/features/notes_on_merge_requests_spec.rb index 389812ff7e1..9e2bdd7f5bb 100644 --- a/spec/features/notes_on_merge_requests_spec.rb +++ b/spec/features/notes_on_merge_requests_spec.rb @@ -8,7 +8,7 @@ describe 'Comments', feature: true do it 'excludes award_emoji from comment count' do merge_request = create(:merge_request) project = merge_request.source_project - create(:upvote_note, noteable: merge_request, project: project) + create(:award_emoji, awardable: merge_request, project: project) login_as :admin visit namespace_project_merge_requests_path(project.namespace, project) @@ -146,7 +146,7 @@ describe 'Comments', feature: true do describe 'comment info' do it 'excludes award_emoji from comment count' do - create(:upvote_note, noteable: merge_request, project: project) + create(:award_emoji, awardable: merge_request, project: project) visit namespace_project_merge_request_path(project.namespace, project, merge_request) diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index bffe2c18b6f..eae61a54dfc 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -163,18 +163,15 @@ describe IssuesHelper do it { is_expected.to eq("!1, !2, or !3") } end - describe "note_active_class" do - before do - @note = create :note - @note1 = create :note - end + describe '#award_active_class' do + let!(:upvote) { create(:award_emoji) } it "returns empty string for unauthenticated user" do - expect(note_active_class(Note.all, nil)).to eq("") + expect(award_active_class(AwardEmoji.all, nil)).to eq("") end it "returns active string for author" do - expect(note_active_class(Note.all, @note.author)).to eq("active") + expect(award_active_class(AwardEmoji.all, upvote.user)).to eq("active") end end diff --git a/spec/lib/award_emoji_spec.rb b/spec/lib/gitlab/award_emoji_spec.rb index 88c22912950..4e6c04a11b9 100644 --- a/spec/lib/award_emoji_spec.rb +++ b/spec/lib/gitlab/award_emoji_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' -describe AwardEmoji do +describe Gitlab::AwardEmoji do describe '.urls' do - subject { AwardEmoji.urls } + subject { Gitlab::AwardEmoji.urls } it { is_expected.to be_an_instance_of(Array) } it { is_expected.to_not be_empty } @@ -19,7 +19,7 @@ describe AwardEmoji do describe '.emoji_by_category' do it "only contains known categories" do - undefined_categories = AwardEmoji.emoji_by_category.keys - AwardEmoji::CATEGORIES.keys + undefined_categories = Gitlab::AwardEmoji.emoji_by_category.keys - Gitlab::AwardEmoji::CATEGORIES.keys expect(undefined_categories).to be_empty end end diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb new file mode 100644 index 00000000000..fd3712b7d43 --- /dev/null +++ b/spec/models/award_emoji_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe AwardEmoji, models: true do + describe 'Associations' do + it { is_expected.to belong_to(:awardable) } + it { is_expected.to belong_to(:user) } + end + + describe 'modules' do + it { is_expected.to include_module(Participable) } + end + + describe "validations" do + it { is_expected.to validate_presence_of(:awardable) } + it { is_expected.to validate_presence_of(:user) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:awardable) } + + # To circumvent a bug in the shoulda matchers + describe "scoped uniqueness validation" do + it "rejects duplicate award emoji" do + user = create(:user) + issue = create(:issue) + create(:award_emoji, user: user, awardable: issue) + new_award = AwardEmoji.new(user: user, awardable: issue, name: "thumbsup") + + expect(new_award).not_to be_valid + end + end + end +end diff --git a/spec/models/concerns/awardable_spec.rb b/spec/models/concerns/awardable_spec.rb new file mode 100644 index 00000000000..6851d068367 --- /dev/null +++ b/spec/models/concerns/awardable_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Issue, "Awardable" do + let!(:issue) { create(:issue) } + let!(:award_emoji) { create(:award_emoji, :downvote, awardable: issue) } + + describe "Associations" do + it { is_expected.to have_many(:award_emoji).dependent(:destroy) } + end + + describe "ClassMethods" do + let!(:issue2) { create(:issue) } + + before do + create(:award_emoji, awardable: issue2) + end + + it "orders on upvotes" do + expect(Issue.order_upvotes_desc.to_a).to eq [issue2, issue] + end + + it "orders on downvotes" do + expect(Issue.order_downvotes_desc.to_a).to eq [issue, issue2] + end + end + + describe "#upvotes" do + it "counts the number of upvotes" do + expect(issue.upvotes).to be 0 + end + end + + describe "#downvotes" do + it "counts the number of downvotes" do + expect(issue.downvotes).to be 1 + end + end + + describe "#toggle_award_emoji" do + it "adds an emoji if it isn't awarded yet" do + expect { issue.toggle_award_emoji("thumbsup", award_emoji.user) }.to change { AwardEmoji.count }.by 1 + end + + it "toggles already awarded emoji" do + + expect { issue.toggle_award_emoji("thumbsdown", award_emoji.user) }.to change { AwardEmoji.count }.by -1 + end + end +end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 4a4cd093435..568bf4c9324 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -12,6 +12,10 @@ describe Issue, "Issuable" do it { is_expected.to have_many(:todos).dependent(:destroy) } end + describe 'Included modules' do + it { is_expected.to include_module(Awardable) } + end + describe "Validation" do before do allow(subject).to receive(:set_iid).and_return(false) @@ -199,12 +203,11 @@ describe Issue, "Issuable" do end end + # TODO ZJ describe "votes" do before do - author = create :user - project = create :empty_project - issue.notes.awards.create!(note: "thumbsup", author: author, project: project) - issue.notes.awards.create!(note: "thumbsdown", author: author, project: project) + create!(:award_emoji, :upvote, awardable: issue) + create!(:award_emoji, :downvote, awardable: issue) end it "returns correct values" do diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 4b788b57882..b479f1c2f1a 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -131,23 +131,6 @@ describe Note, models: true do end end - describe '.grouped_awards' do - before do - create :note, note: "smile", is_award: true - create :note, note: "smile", is_award: true - end - - it "returns grouped hash of notes" do - expect(Note.grouped_awards.keys.size).to eq(3) - expect(Note.grouped_awards["smile"]).to match_array(Note.all) - end - - it "returns thumbsup and thumbsdown always" do - expect(Note.grouped_awards["thumbsup"]).to match_array(Note.none) - expect(Note.grouped_awards["thumbsdown"]).to match_array(Note.none) - end - end - describe '#active?' do it 'is always true when the note has no associated diff' do note = build(:note) @@ -218,11 +201,6 @@ describe Note, models: true do note = build(:note, system: true) expect(note.editable?).to be_falsy end - - it "returns false" do - note = build(:note, is_award: true, note: "smiley") - expect(note.editable?).to be_falsy - end end describe "cross_reference_not_visible_for?" do @@ -249,23 +227,6 @@ describe Note, models: true do end end - describe "set_award!" do - let(:merge_request) { create :merge_request } - - it "converts aliases to actual name" do - note = create(:note, note: ":+1:", noteable: merge_request) - expect(note.reload.note).to eq("thumbsup") - end - - it "is not an award emoji when comment is on a diff" do - note = create(:note, note: ":blowfish:", noteable: merge_request, line_code: "11d5d2e667e9da4f7f610f81d86c974b146b13bd_0_2") - note = note.reload - - expect(note.note).to eq(":blowfish:") - expect(note.is_award?).to be_falsy - end - end - describe 'clear_blank_line_code!' do it 'clears a blank line code before validation' do note = build(:note, line_code: ' ') diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 26d4e139396..b60725756cc 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -30,6 +30,7 @@ describe User, models: true do it { is_expected.to have_one(:abuse_report) } it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } + it { is_expected.to have_many(:award_emoji).dependent(:destroy) } end describe 'validations' do diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index ff23f13e1cb..93e8a7094e2 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -14,7 +14,7 @@ describe Notes::CreateService, services: true do noteable_type: 'Issue', noteable_id: issue.id } - + @note = Notes::CreateService.new(project, user, opts).execute end @@ -28,18 +28,16 @@ describe Notes::CreateService, services: true do project.team << [user, :master] end - it "creates emoji note" do + it "creates an award emoji" do opts = { note: ':smile: ', noteable_type: 'Issue', noteable_id: issue.id } + note = Notes::CreateService.new(project, user, opts).execute - @note = Notes::CreateService.new(project, user, opts).execute - - expect(@note).to be_valid - expect(@note.note).to eq('smile') - expect(@note.is_award).to be_truthy + expect(note).to be_valid + expect(note.name).to eq('smile') end it "creates regular note if emoji name is invalid" do @@ -48,12 +46,10 @@ describe Notes::CreateService, services: true do noteable_type: 'Issue', noteable_id: issue.id } + note = Notes::CreateService.new(project, user, opts).execute - @note = Notes::CreateService.new(project, user, opts).execute - - expect(@note).to be_valid - expect(@note.note).to eq(opts[:note]) - expect(@note.is_award).to be_falsy + expect(note).to be_valid + expect(note.note).to eq(opts[:note]) end end end diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index a075496ee63..0d88b77ad74 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -156,7 +156,6 @@ describe TodoService, services: true do let(:note_on_commit) { create(:note_on_commit, project: project, author: john_doe, note: mentions) } let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project, note: mentions) } let(:note_on_project_snippet) { create(:note_on_project_snippet, project: project, author: john_doe, note: mentions) } - let(:award_note) { create(:note, :award, project: project, noteable: issue, author: john_doe, note: 'thumbsup') } let(:system_note) { create(:system_note, project: project, noteable: issue) } it 'mark related pending todos to the noteable for the note author as done' do @@ -169,13 +168,6 @@ describe TodoService, services: true do expect(second_todo.reload).to be_done end - it 'mark related pending todos to the noteable for the award note author as done' do - service.new_note(award_note, john_doe) - - expect(first_todo.reload).to be_done - expect(second_todo.reload).to be_done - end - it 'does not mark related pending todos it is a system note' do service.new_note(system_note, john_doe) @@ -305,6 +297,15 @@ describe TodoService, services: true do expect(second_todo.reload).to be_done end end + + describe '#new_award_emoji' do + it 'marks related pending todos to the target for the user as done' do + todo = create(:todo, user: john_doe, project: project, target: mr_assigned, author: author) + service.new_award_emoji(mr_assigned, john_doe) + + expect(todo.reload).to be_done + end + end end def should_create_todo(attributes = {}) diff --git a/spec/services/toggle_award_emoji_service_spec.rb b/spec/services/toggle_award_emoji_service_spec.rb new file mode 100644 index 00000000000..3d2f497fdec --- /dev/null +++ b/spec/services/toggle_award_emoji_service_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe ToggleAwardEmoji, services: true do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:issue) { create(:issue, project: project) } + + before do + project.team << [user, :master] + end + + describe '#execute' do + it 'removes related todos' do + expect_any_instance_of(TodoService).to receive(:new_award_emoji).with(issue, user) + + ToggleAwardEmojiService.new(project, user).execute(issue, "thumbsdown") + end + + it 'normalizes the emoji name' do + expect(issue).to receive(:toggle_award_emoji).with("thumbsup", user) + + ToggleAwardEmojiService.new(project, user).execute(issue, ":+1:") + end + + context 'when the emoji is set' do + it 'removes the emoji' do + create(:award_emoji, awardable: issue, user: user) + + expect { ToggleAwardEmojiService.new(project, user).execute(issue, ":+1:") }.to change { AwardEmoji.count }.by(-1) + end + end + + context 'when the award is not set yet' do + it 'awards the emoji' do + expect { ToggleAwardEmojiService.new(project, user).execute(issue, ":+1:") }.to change { AwardEmoji.count }.by(1) + end + end + end +end |