Project

General

Profile

Feature #13919 » 0001-Demo-for-acts_as_mentionable.patch

Marius BĂLTEANU, 2020-04-05 21:17

View differences:

app/controllers/auto_completes_controller.rb
42 42
    render :json => format_issues_json(issues)
43 43
  end
44 44

  
45
  def users
46
    users = []
47
    q = (params[:q] || params[:term]).to_s.strip
48
    scope = nil
49
    if params[:q].blank? && @project.present?
50
      scope = @project.users
51
    else
52
      scope = User.all.limit(10)
53
    end
54
    users = scope.active.visible.sorted.like(params[:q]).to_a
55
    render :json => format_users_json(users)
56
  end
57

  
45 58
  private
46 59

  
47 60
  def find_project
......
61 74
      }
62 75
    }
63 76
  end
77

  
78
  def format_users_json(users)
79
    users.map {|user| {
80
        'firstname' => user.firstname,
81
        'lastname' => user.lastname,
82
        'name' => user.name,
83
        'login' => user.login
84
      }
85
    }
86
  end
64 87
end
app/models/issue.rb
54 54
  acts_as_activity_provider :scope => preload(:project, :author, :tracker, :status),
55 55
                            :author_key => :author_id
56 56

  
57
  acts_as_mentionable :attributes => ['description']
58

  
57 59
  DONE_RATIO_OPTIONS = %w(issue_field issue_status)
58 60

  
59 61
  attr_reader :transition_warning
app/models/journal.rb
42 42
                                      joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
43 43
                                      where("#{Journal.table_name}.journalized_type = 'Issue' AND" +
44 44
                                            " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')").distinct
45
  acts_as_mentionable :attributes => ['notes']
45 46

  
46 47
  before_create :split_private_notes
47 48
  after_create_commit :send_notification
app/models/mailer.rb
68 68
  end
69 69

  
70 70
  # Builds a mail for notifying user about a new issue
71
  def issue_add(user, issue)
71
  def issue_add(user, issue, reason = nil)
72 72
    redmine_headers 'Project' => issue.project.identifier,
73 73
                    'Issue-Tracker' => issue.tracker.name,
74 74
                    'Issue-Id' => issue.id,
......
79 79
    @author = issue.author
80 80
    @issue = issue
81 81
    @user = user
82
    @reason = reason
82 83
    @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue)
83 84
    subject = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]"
84 85
    subject += " (#{issue.status.name})" if Setting.show_status_changes_in_mail_subject?
......
93 94
  #   Mailer.deliver_issue_add(issue)
94 95
  def self.deliver_issue_add(issue)
95 96
    users = issue.notified_users | issue.notified_watchers
97

  
96 98
    users.each do |user|
97 99
      issue_add(user, issue).deliver_later
98 100
    end
101

  
102
    mentions = issue.notified_mentions
103
    users.each do |user|
104
      issue_add(user, issue, 'mentioned').deliver_later
105
    end
99 106
  end
100 107

  
101 108
  # Builds a mail for notifying user about an issue update
102
  def issue_edit(user, journal)
109
  def issue_edit(user, journal, reason = nil)
103 110
    issue = journal.journalized
104 111
    redmine_headers 'Project' => issue.project.identifier,
105 112
                    'Issue-Tracker' => issue.tracker.name,
......
115 122
    @issue = issue
116 123
    @user = user
117 124
    @journal = journal
125
    @reason = reason
118 126
    @journal_details = journal.visible_details
119 127
    @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue, :anchor => "change-#{journal.id}")
120 128

  
......
128 136
  #   Mailer.deliver_issue_edit(journal)
129 137
  def self.deliver_issue_edit(journal)
130 138
    users  = journal.notified_users | journal.notified_watchers
139

  
131 140
    users.select! do |user|
132 141
      journal.notes? || journal.visible_details(user).any?
133 142
    end
134 143
    users.each do |user|
135 144
      issue_edit(user, journal).deliver_later
136 145
    end
146

  
147
    notified_mentions = journal.journalized.notified_mentions | journal.notified_mentions
148
    notified_mentions.each do |user|
149
      issue_edit(user, journal, 'mentioned').deliver_later
150
    end
137 151
  end
138 152

  
139 153
  # Builds a mail to user about a new document.
......
220 234
  # Example:
221 235
  #   Mailer.deliver_news_added(news)
222 236
  def self.deliver_news_added(news)
223
    users = news.notified_users | news.notified_watchers_for_added_news
237
    users = news.notified_users | news.notified_watchers_for_added_news | news.notified_mentions
224 238
    users.each do |user|
225 239
      news_added(user, news).deliver_later
226 240
    end
......
301 315
  # Example:
302 316
  #   Mailer.deliver_wiki_content_added(wiki_content)
303 317
  def self.deliver_wiki_content_added(wiki_content)
304
    users = wiki_content.notified_users | wiki_content.page.wiki.notified_watchers
318
    users = wiki_content.notified_users | wiki_content.page.wiki.notified_watchers | wiki_content.notified_mentions
305 319
    users.each do |user|
306 320
      wiki_content_added(user, wiki_content).deliver_later
307 321
    end
......
333 347
    users  = wiki_content.notified_users
334 348
    users |= wiki_content.page.notified_watchers
335 349
    users |= wiki_content.page.wiki.notified_watchers
350
    users |= wiki_content.notified_mentions
336 351

  
337 352
    users.each do |user|
338 353
      wiki_content_updated(user, wiki_content).deliver_later
app/models/wiki_content.rb
24 24
  belongs_to :page, :class_name => 'WikiPage'
25 25
  belongs_to :author, :class_name => 'User'
26 26
  has_many :versions, :class_name => 'WikiContentVersion', :dependent => :delete_all
27

  
28
  acts_as_mentionable :attributes => ['text']
29

  
27 30
  validates_presence_of :text
28 31
  validates_length_of :comments, :maximum => 1024, :allow_nil => true
29 32

  
app/views/documents/_form.html.erb
6 6
<p><%= f.text_area :description, :cols => 60, :rows => 15, :class => 'wiki-edit',
7 7
                  :data => {
8 8
                      :auto_complete => true,
9
                      :issues_url => auto_complete_issues_path(:project_id => @project, :q => '')
9
                      :issues_url => auto_complete_issues_path(:project_id => @project, :q => ''),
10
                      :users_url => auto_complete_users_path(:project_id => @project, :q => '')
10 11
                  }
11 12
%></p>
12 13

  
app/views/issues/_edit.html.erb
32 32
      <%= f.text_area :notes, :cols => 60, :rows => 10, :class => 'wiki-edit',
33 33
            :data => {
34 34
                :auto_complete => true,
35
                :issues_url => auto_complete_issues_path(:project_id => @issue.project, :q => '')
35
                :issues_url => auto_complete_issues_path(:project_id => @issue.project, :q => ''),
36
                :users_url => auto_complete_users_path(:project_id => @issue.project, :q => '')
36 37
            },
37 38
            :no_label => true %>
38 39
      <%= wikitoolbar_for 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) %>
app/views/issues/_form.html.erb
38 38
                   :rows => [[10, @issue.description.to_s.length / 50].max, 20].min,
39 39
                   :data => {
40 40
                       :auto_complete => true,
41
                       :issues_url => auto_complete_issues_path(:project_id => @issue.project, :q => '')
41
                       :issues_url => auto_complete_issues_path(:project_id => @issue.project, :q => ''),
42
                       :users_url => auto_complete_users_path(:project_id => @issue.project, :q => '')
42 43
                   },
43 44
                   :no_label => true %>
44 45
  <% end %>
app/views/issues/bulk_edit.html.erb
197 197
<%= text_area_tag 'notes', @notes, :cols => 60, :rows => 10, :class => 'wiki-edit',
198 198
                  :data => {
199 199
                      :auto_complete => true,
200
                      :issues_url => auto_complete_issues_path(:project_id => @project, :q => '')
200
                      :issues_url => auto_complete_issues_path(:project_id => @project, :q => ''),
201
                      :users_url => auto_complete_users_path(:project_id => @project, :q => '')
201 202
                  }
202 203
%>
203 204
<%= wikitoolbar_for 'notes' %>
app/views/journals/_notes_form.html.erb
7 7
          :rows => (@journal.notes.blank? ? 10 : [[10, @journal.notes.length / 50].max, 100].min),
8 8
          :data => {
9 9
              :auto_complete => true,
10
              :issues_url => auto_complete_issues_path(:project_id => @project, :q => '')
10
              :issues_url => auto_complete_issues_path(:project_id => @project, :q => ''),
11
              :users_url => auto_complete_users_path(:project_id => @project, :q => '')
11 12
          }
12 13
    %>
13 14
    <% if @journal.safe_attribute? 'private_notes' %>
app/views/mailer/issue_add.html.erb
1 1
<%= l(:text_issue_added, :id => link_to("##{@issue.id}", @issue_url), :author => h(@issue.author)).html_safe %>
2
<% unless @reason.nil? %>
3
  You have been mentioned in it.
4
<% end %>
2 5
<hr />
3 6
<%= render :partial => 'issue', :formats => [:html], :locals => { :issue => @issue, :user => @user, :issue_url => @issue_url } %>
app/views/mailer/issue_edit.html.erb
2 2
  (<%= l(:field_private_notes) %>)
3 3
<% end %>
4 4
<%= l(:text_issue_updated, :id => link_to("##{@issue.id}", @issue_url), :author => h(@journal.user)).html_safe %>
5
<% unless @reason.nil? %>
6
  You have been mentioned in it.
7
<% end %>
5 8
<hr />
6 9

  
7 10
<ul class="journal details">
app/views/messages/_form.html.erb
27 27
                :accesskey => accesskey(:edit),
28 28
                :data => {
29 29
                    :auto_complete => true,
30
                    :issues_url => auto_complete_issues_path(:project_id => @project, :q => '')
30
                    :issues_url => auto_complete_issues_path(:project_id => @project, :q => ''),
31
                    :users_url => auto_complete_users_path(:project_id => @project, :q => '')
31 32
                }
32 33
%></p>
33 34
<%= wikitoolbar_for 'message_content', preview_board_message_path(:board_id => @board, :id => @message) %>
app/views/news/_form.html.erb
6 6
<p><%= f.text_area :description, :required => true, :cols => 60, :rows => 15, :class => 'wiki-edit',
7 7
                   :data => {
8 8
                       :auto_complete => true,
9
                       :issues_url => auto_complete_issues_path(:project_id => @project, :q => '')
9
                       :issues_url => auto_complete_issues_path(:project_id => @project, :q => ''),
10
                       :users_url => auto_complete_users_path(:project_id => @project, :q => '')
10 11
                   }
11 12
%></p>
12 13
<p id="attachments_form"><label><%= l(:label_attachment_plural) %></label><%= render :partial => 'attachments/form', :locals => {:container => @news} %></p>
app/views/news/show.html.erb
56 56
    <%= text_area 'comment', 'comments', :cols => 80, :rows => 15, :class => 'wiki-edit',
57 57
                  :data => {
58 58
                    :auto_complete => true,
59
                    :issues_url => auto_complete_issues_path(:project_id => @project, :q => '')
59
                    :issues_url => auto_complete_issues_path(:project_id => @project, :q => ''),
60
                    :users_url => auto_complete_users_path(:project_id => @project, :q => '')
60 61
                  }
61 62
    %>
62 63
    <%= wikitoolbar_for 'comment_comments', preview_news_path(:project_id => @project, :id => @news) %>
app/views/wiki/edit.html.erb
17 17
                  :class => 'wiki-edit',
18 18
                  :data => {
19 19
                      :auto_complete => true,
20
                      :issues_url => auto_complete_issues_path(:project_id => @project, :q => '')
20
                      :issues_url => auto_complete_issues_path(:project_id => @project, :q => ''),
21
                      :users_url => auto_complete_users_path(:project_id => @project, :q => '')
21 22
                  }
22 23
%>
23 24

  
config/routes.rb
45 45

  
46 46
  # Misc issue routes. TODO: move into resources
47 47
  match '/issues/auto_complete', :to => 'auto_completes#issues', :via => :get, :as => 'auto_complete_issues'
48
  match '/users/auto_complete', :to => 'auto_completes#users', :via => :get, :as => 'auto_complete_users'
48 49
  match '/issues/context_menu', :to => 'context_menus#issues', :as => 'issues_context_menu', :via => [:get, :post]
49 50
  match '/issues/changes', :to => 'journals#index', :as => 'issue_changes', :via => :get
50 51
  match '/issues/:id/quoted', :to => 'journals#new', :id => /\d+/, :via => :post, :as => 'quoted_issue'
lib/redmine.rb
31 31
end
32 32

  
33 33
require 'redmine/acts/positioned'
34
require 'redmine/acts/mentionable'
34 35

  
35 36
require 'redmine/scm/base'
36 37
require 'redmine/access_control'
lib/redmine/acts/mentionable.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2019  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module Acts
22
    module Mentionable
23
      def self.included(base)
24
        base.extend ClassMethods
25
      end
26

  
27
      module ClassMethods
28
        def acts_as_mentionable(options = {})
29
          class_attribute :mentionable_attributes
30
          self.mentionable_attributes = options[:attributes]
31

  
32
          attr_accessor :mentioned_users
33

  
34
          send :include, Redmine::Acts::Mentionable::InstanceMethods
35

  
36
          after_save :parse_mentions
37
        end
38
      end
39

  
40
      module InstanceMethods
41
        def self.included(base)
42
          base.extend ClassMethods
43
        end
44

  
45
        def notified_mentions
46
          notified = mentioned_users.to_a
47
          notified.reject! {|user| user.mail.blank? || user.mail_notification == 'none'}
48
          notified
49
        end
50

  
51
        private
52

  
53
        def parse_mentions
54
          mentionable_attrs = self.mentionable_attributes
55
          saved_mentionable_attrs = self.saved_changes.select{|a| mentionable_attrs.include?(a)}
56

  
57
          saved_mentionable_attrs.each do |key, attr|
58
            old_value, new_value =  attr
59
            get_mentioned_users(old_value, new_value)
60
          end
61
        end
62

  
63
        def get_mentioned_users(old_content, new_content)
64
          previous_matches =  scan_for_mentioned_users(old_content)
65
          current_matches = scan_for_mentioned_users(new_content)
66
          new_matches = (current_matches - previous_matches).flatten
67

  
68
          if new_matches.any?
69
            self.mentioned_users = User.visible.active.where(:login => new_matches)
70
          end
71
        end
72

  
73
        def scan_for_mentioned_users(content)
74
          return [] if content.nil?
75

  
76
          # remove quoted text
77
          content = content.gsub(%r{\r\n(?:\>\s)+(.*?)\r\n}m, '')
78

  
79
          text_formatting = Setting.text_formatting
80
          # Remove text wrapped in pre tags based on text formatting
81
          if text_formatting == 'textile'
82
            content = content.gsub(%r{<pre>(.*?)</pre>}m, '')
83
          elsif text_formatting == 'markdown'
84
            content = content.gsub(%r{(~~~|```)(.*?)(~~~|```)}m, '')
85
          end
86

  
87
          content.scan(/@([a-z0-9_\-@\.]*)/i)
88
        end
89
      end
90
    end
91
  end
92
end
93

  
94
ActiveRecord::Base.send :include, Redmine::Acts::Mentionable
public/javascripts/application.js
1049 1049
    };
1050 1050

  
1051 1051
    const tribute = new Tribute({
1052
      trigger: '#',
1053
      values: function (text, cb) {
1054
        if (event.target.type === 'text' && $(element).attr('autocomplete') != 'off') {
1055
          $(element).attr('autocomplete', 'off');
1056
        }
1057
        remoteSearch(issuesUrl + text, function (issues) {
1058
          return cb(issues);
1059
        });
1060
      },
1061
      lookup: 'label',
1062
      fillAttr: 'label',
1063
      requireLeadingSpace: true,
1064
      selectTemplate: function (issue) {
1065
        return '#' + issue.original.id;
1066
      }
1052
        collection: [
1053
            {
1054
                trigger: '#',
1055
                values: function (text, cb) {
1056
                  if (event.target.type === 'text' && $(element).attr('autocomplete') != 'off') {
1057
                    $(element).attr('autocomplete', 'off');
1058
                  }
1059
                  remoteSearch(issuesUrl + text, function (issues) {
1060
                    return cb(issues);
1061
                  });
1062
                },
1063
                lookup: 'label',
1064
                fillAttr: 'label',
1065
                requireLeadingSpace: true,
1066
                selectTemplate: function (issue) {
1067
                  return '#' + issue.original.id;
1068
                },
1069
            },
1070
            {
1071
                trigger: '@',
1072
                lookup: function (user, mentionText) {
1073
                  return user.name + user.firstname + user.lastname + user.login;
1074
                },
1075
                values: function (text, cb) {
1076
                  remoteSearch(usersUrl + text, function (users) {
1077
                    return cb(users);
1078
                  });
1079
                },
1080
                menuItemTemplate: function (user) {
1081
                  return user.original.name;
1082
                },
1083
                selectTemplate: function (user) {
1084
                  return '@' + user.original.login;
1085
                }
1086
            }
1087
        ]
1067 1088
    });
1068 1089

  
1069 1090
    tribute.attach(element);
test/functional/auto_completes_controller_test.rb
78 78
    assert_include "Bug #13", response.body
79 79
  end
80 80

  
81
  def test_auto_complete_with_scope_all_should_search_other_projects
81
  def test_issues_with_scope_all_should_search_other_projects
82 82
    get(
83 83
      :issues,
84 84
      :params => {
......
91 91
    assert_include "Bug #13", response.body
92 92
  end
93 93

  
94
  def test_auto_complete_without_project_should_search_all_projects
94
  def test_issues_without_project_should_search_all_projects
95 95
    get(:issues, :params => {:q => '13'})
96 96
    assert_response :success
97 97
    assert_include "Bug #13", response.body
98 98
  end
99 99

  
100
  def test_auto_complete_without_scope_all_should_not_search_other_projects
100
  def test_issues_without_scope_all_should_not_search_other_projects
101 101
    get(
102 102
      :issues,
103 103
      :params => {
......
127 127
    assert_equal 'Bug #13: Subproject issue two', issue['label']
128 128
  end
129 129

  
130
  def test_auto_complete_with_status_o_should_return_open_issues_only
130
  def test_issues_with_status_o_should_return_open_issues_only
131 131
    get(
132 132
      :issues,
133 133
      :params => {
......
141 141
    assert_not_include "closed", response.body
142 142
  end
143 143

  
144
  def test_auto_complete_with_status_c_should_return_closed_issues_only
144
  def test_issues_with_status_c_should_return_closed_issues_only
145 145
    get(
146 146
      :issues,
147 147
      :params => {
......
155 155
    assert_not_include "Issue due today", response.body
156 156
  end
157 157

  
158
  def test_auto_complete_with_issue_id_should_not_return_that_issue
158
  def test_issues_with_issue_id_should_not_return_that_issue
159 159
    get(
160 160
      :issues,
161 161
      :params => {
......
181 181
    assert_include 'application/json', response.headers['Content-Type']
182 182
  end
183 183

  
184
  def test_auto_complete_without_term_should_return_last_10_issues
184
  def test_issue_without_term_should_return_last_10_issues
185 185
    # There are 9 issues generated by fixtures
186 186
    # and we need two more to test the 10 limit
187 187
    %w(1..2).each do
......
196 196
    assert_equal 10, json.count
197 197
    assert_equal Issue.last.id, json.first['id'].to_i
198 198
  end
199

  
200
  def test_users_should_accept_case_insensitive_term_param
201
    get :users, :params => {
202
        :q => 'JoHN'
203
    }
204
    assert_response :success
205
    assert_include "John Smith", response.body
206
  end
207

  
208
  def test_users_should_return_json
209
    get :users, :params => {
210
        :q => 'john'
211
    }
212
    assert_response :success
213
    json = ActiveSupport::JSON.decode(response.body)
214
    assert_kind_of Array, json
215
    user = json.first
216
    assert_kind_of Hash, user
217
    assert_equal 'John', user['firstname']
218
    assert_equal 'Smith', user['lastname']
219
    assert_equal 'John Smith', user['name']
220
    assert_equal 'jsmith', user['login']
221
  end
222

  
223
  def test_users_with_project_and_without_term_should_return_project_users
224
    get :users, :params => {
225
        :project_id => 'onlinestore'
226
    }
227
    assert_response :success
228
    json = ActiveSupport::JSON.decode(response.body)
229
    assert_equal 2, json.count
230
    assert_equal 'jsmith', json.first['login']
231
    assert_equal 'miscuser8', json.last['login']
232
  end
233

  
234
  def test_users_without_project_and_with_term_should_search_in_all_users
235
    get :users, :params => {
236
        :q => 'm'
237
    }
238
    assert_response :success
239
    assert_include "Redmine Admin", response.body
240
    assert_include "Dave Lopper", response.body
241
  end
199 242
end
(7-7/12)