Project

General

Profile

Feature #41294 » 0003-Add-the-ability-to-quote-partial-text.patch

Katsuya HIDAKA, 2024-09-22 13:01

View differences:

app/assets/javascripts/application.js
1262 1262
    tribute.attach(element);
1263 1263
}
1264 1264

  
1265
function quoteReply(path) {
1265
function quoteReply(path, selectorForContentElement) {
1266
  const contentElement = $(selectorForContentElement).get(0);
1267
  const quote = QuoteExtractor.extract(contentElement);
1268

  
1266 1269
  $.ajax({
1267 1270
    url: path,
1268
    type: 'post'
1271
    type: 'post',
1272
    data: { quote: quote }
1269 1273
  });
1270 1274
}
1271 1275

  
1276
class QuoteExtractor {
1277
  static extract(targetElement) {
1278
    return new QuoteExtractor(targetElement).extract();
1279
  }
1280

  
1281
  constructor(targetElement) {
1282
    this.targetElement = targetElement;
1283
    this.selection = window.getSelection();
1284
  }
1285

  
1286
  extract() {
1287
    const range = this.selectedRange;
1288

  
1289
    if (!range) {
1290
      return null;
1291
    }
1292

  
1293
    if (!this.targetElement.contains(range.startContainer)) {
1294
      range.setStartBefore(this.targetElement);
1295
    }
1296
    if (!this.targetElement.contains(range.endContainer)) {
1297
      range.setEndAfter(this.targetElement);
1298
    }
1299

  
1300
    return this.formatRange(range);
1301
  }
1302

  
1303
  formatRange(range) {
1304
    return range.toString().trim();
1305
  }
1306

  
1307
  get selectedRange() {
1308
    if (!this.isSelected) {
1309
      return null;
1310
    }
1311

  
1312
    // Retrive the first range that intersects with the target element.
1313
    // NOTE: Firefox allows to select multiple ranges in the document.
1314
    for (let i = 0; i < this.selection.rangeCount; i++) {
1315
      let range = this.selection.getRangeAt(i);
1316
      if (range.intersectsNode(this.targetElement)) {
1317
        return range;
1318
      }
1319
    }
1320
    return null;
1321
  }
1322

  
1323
  get isSelected() {
1324
    return this.selection.containsNode(this.targetElement, true);
1325
  }
1326
}
1327

  
1272 1328
$(document).ready(setupAjaxIndicator);
1273 1329
$(document).ready(hideOnLoad);
1274 1330
$(document).ready(addFormObserversForDoubleSubmit);
app/controllers/journals_controller.rb
75 75
      @content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
76 76
    end
77 77
    # Replaces pre blocks with [...]
78
    text = text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]')
78
    text = params[:quote].presence || text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]')
79 79
    @content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
80 80
  rescue ActiveRecord::RecordNotFound
81 81
    render_404
app/controllers/messages_controller.rb
124 124
    else
125 125
      @content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => @message.author, :link => "message##{@message.id}"})}\n> "
126 126
    end
127
    @content << @message.content.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
127

  
128
    quote_text = params[:quote].presence || @message.content.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]')
129
    @content << quote_text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
128 130

  
129 131
    respond_to do |format|
130 132
      format.html { render_404 }
app/helpers/journals_helper.rb
40 40

  
41 41
    if journal.notes.present?
42 42
      if options[:reply_links]
43
        url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
43 44
        links << link_to_function(icon_with_label('comment', l(:button_quote)),
44
                                  "quoteReply('#{j quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)}')",
45
                                  "quoteReply('#{j url}', '#journal-#{j journal.id}-notes')",
45 46
                                  :title => l(:button_quote),
46 47
                                  :class => 'icon-only icon-comment'
47 48
                                 )
app/views/issues/show.html.erb
84 84
<hr />
85 85
<div class="description">
86 86
  <div class="contextual">
87
  <%= link_to_function icon_with_label('comment', l(:button_quote)), "quoteReply('#{j quoted_issue_path(@issue)}')",:class => 'icon icon-comment ' if @issue.notes_addable? %>
87
  <%= link_to_function(icon_with_label('comment', l(:button_quote)),
88
                       "quoteReply('#{j quoted_issue_path(@issue)}', '#issue_description_wiki')",
89
                       :class => 'icon icon-comment '
90
                      ) if @issue.notes_addable? %>
88 91
  </div>
89 92

  
90 93
  <p><strong><%=l(:field_description)%></strong></p>
91
  <div class="wiki">
94
  <div id="issue_description_wiki" class="wiki">
92 95
  <%= textilizable @issue, :description, :attachments => @issue.attachments %>
93 96
  </div>
94 97
</div>
app/views/messages/show.html.erb
4 4
    <%= watcher_link(@topic, User.current) %>
5 5
    <%= link_to_function(
6 6
          icon_with_label('comment', l(:button_quote)),
7
          "quoteReply('#{j url_for(:action => 'quote', :id => @topic, :format => 'js')}')",
7
          "quoteReply('#{j url_for(:action => 'quote', :id => @topic, :format => 'js')}', '#message_topic_wiki')",
8 8
          :class => 'icon icon-comment') if !@topic.locked? && authorize_for('messages', 'reply') %>
9 9
    <%= link_to(
10 10
          icon_with_label('edit', l(:button_edit)),
......
24 24

  
25 25
<div class="message">
26 26
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
27
<div class="wiki">
27
<div id="message_topic_wiki" class="wiki">
28 28
<%= textilizable(@topic, :content) %>
29 29
</div>
30 30
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
......
42 42
    <div class="contextual">
43 43
      <%= link_to_function(
44 44
            icon_with_label('comment', l(:button_quote), icon_only: true),
45
            "quoteReply('#{j url_for(:action => 'quote', :id => message, :format => 'js')}')",
45
            "quoteReply('#{j url_for(:action => 'quote', :id => message, :format => 'js')}', '#message-#{j message.id} .wiki')",
46 46
            :title => l(:button_quote),
47 47
            :class => 'icon icon-comment'
48 48
          ) if !@topic.locked? && authorize_for('messages', 'reply') %>
test/functional/journals_controller_test.rb
226 226
    assert_response :not_found
227 227
  end
228 228

  
229
  def test_reply_to_issue_with_partial_quote
230
    @request.session[:user_id] = 2
231

  
232
    params = { id: 6, quote: 'a private subproject of cookbook' }
233
    post :new, params: params, xhr: true
234

  
235
    assert_response :success
236
    assert_equal 'text/javascript', response.media_type
237
    assert_include 'John Smith wrote:', response.body
238
    assert_include '> a private subproject of cookbook', response.body
239
  end
240

  
241
  def test_reply_to_note_with_partial_quote
242
    @request.session[:user_id] = 2
243

  
244
    params = { id: 6, journal_id: 4, journal_indice: 1, quote: 'a private version' }
245
    post :new, params: params, xhr: true
246

  
247
    assert_response :success
248
    assert_equal 'text/javascript', response.media_type
249
    assert_include 'Redmine Admin wrote in #note-1:', response.body
250
    assert_include '> a private version', response.body
251
  end
252

  
229 253
  def test_edit_xhr
230 254
    @request.session[:user_id] = 1
231 255
    get(:edit, :params => {:id => 2}, :xhr => true)
test/functional/messages_controller_test.rb
322 322
    assert_include '> An other reply', response.body
323 323
  end
324 324

  
325
  def test_quote_with_partial_quote_if_message_is_root
326
    @request.session[:user_id] = 2
327

  
328
    params = { board_id: 1, id: 1,
329
               quote: "the very first post\nin the forum" }
330
    post :quote, params: params, xhr: true
331

  
332
    assert_response :success
333
    assert_equal 'text/javascript', response.media_type
334

  
335
    assert_include 'RE: First post', response.body
336
    assert_include "Redmine Admin wrote:", response.body
337
    assert_include '> the very first post\n> in the forum', response.body
338
  end
339

  
340
  def test_quote_with_partial_quote_if_message_is_not_root
341
    @request.session[:user_id] = 2
342

  
343
    params = { board_id: 1, id: 3, quote: 'other reply' }
344
    post :quote, params: params, xhr: true
345

  
346
    assert_response :success
347
    assert_equal 'text/javascript', response.media_type
348

  
349
    assert_include 'RE: First post', response.body
350
    assert_include 'John Smith wrote in message#3:', response.body
351
    assert_include '> other reply', response.body
352
  end
353

  
325 354
  def test_quote_as_html_should_respond_with_404
326 355
    @request.session[:user_id] = 2
327 356
    post(
test/system/issues_reply_test.rb
37 37
      click_link 'Quote'
38 38
    end
39 39

  
40
    # Select the other than the issue description element.
41
    page.execute_script <<-JS
42
      const range = document.createRange();
43
      // Select "Description" text.
44
      range.selectNodeContents(document.querySelector('.description > p'))
45

  
46
      window.getSelection().addRange(range);
47
    JS
48

  
40 49
    assert_field 'issue_notes', with: <<~TEXT
41 50
      John Smith wrote:
42 51
      > Unable to print recipes
......
57 66
    TEXT
58 67
    assert_selector :css, '#issue_notes:focus'
59 68
  end
69

  
70
  def test_reply_to_issue_with_partial_quote
71
    assert_text 'Unable to print recipes'
72

  
73
    # Select only the "print" text from the text "Unable to print recipes" in the description.
74
    page.execute_script <<-JS
75
      const range = document.createRange();
76
      const wiki = document.querySelector('#issue_description_wiki > p').childNodes[0];
77
      range.setStart(wiki, 10);
78
      range.setEnd(wiki, 15);
79

  
80
      window.getSelection().addRange(range);
81
    JS
82

  
83
    within '.issue.details' do
84
      click_link 'Quote'
85
    end
86

  
87
    assert_field 'issue_notes', with: <<~TEXT
88
      John Smith wrote:
89
      > print
90

  
91
    TEXT
92
    assert_selector :css, '#issue_notes:focus'
93
  end
94

  
95
  def test_reply_to_note_with_partial_quote
96
    assert_text 'Journal notes'
97

  
98
    # Select the entire details of the note#1 and the part of the note#1's text.
99
    page.execute_script <<-JS
100
      const range = document.createRange();
101
      range.setStartBefore(document.querySelector('#change-1 .details'));
102
      // Select only the text "Journal" from the text "Journal notes" in the note-1.
103
      range.setEnd(document.querySelector('#change-1 .wiki > p').childNodes[0], 7);
104

  
105
      window.getSelection().addRange(range);
106
    JS
107

  
108
    within '#change-1' do
109
      click_link 'Quote'
110
    end
111

  
112
    assert_field 'issue_notes', with: <<~TEXT
113
      Redmine Admin wrote in #note-1:
114
      > Journal
115

  
116
    TEXT
117
    assert_selector :css, '#issue_notes:focus'
118
  end
60 119
end
test/system/messages_test.rb
54 54

  
55 55
    TEXT
56 56
  end
57

  
58
  def test_reply_to_topic_message_with_partial_quote
59
    assert_text /This is the very first post/
60

  
61
    # Select the part of the topic message through the entire text of the attachment below it.
62
    page.execute_script <<-'JS'
63
      const range = document.createRange();
64
      const message = document.querySelector('#message_topic_wiki');
65
      // Select only the text "in the forum" from the text "This is the very first post\nin the forum".
66
      range.setStartBefore(message.querySelector('p').childNodes[2]);
67
      range.setEndAfter(message.parentNode.querySelector('.attachments'));
68

  
69
      window.getSelection().addRange(range);
70
    JS
71

  
72
    within '#content > .contextual' do
73
      click_link 'Quote'
74
    end
75

  
76
    assert_field 'message_content', with: <<~TEXT
77
      Redmine Admin wrote:
78
      > in the forum
79

  
80
    TEXT
81
  end
82

  
83
  def test_reply_to_message_with_partial_quote
84
    assert_text 'Reply to the first post'
85

  
86
    # Select the entire message, including the subject and headers of messages #2 and #3.
87
    page.execute_script <<-JS
88
      const range = document.createRange();
89
      range.setStartBefore(document.querySelector('#message-2'));
90
      range.setEndAfter(document.querySelector('#message-3'));
91

  
92
      window.getSelection().addRange(range);
93
    JS
94

  
95
    within '#message-2' do
96
      click_link 'Quote'
97
    end
98

  
99
    assert_field 'message_content', with: <<~TEXT
100
      Redmine Admin wrote in message#2:
101
      > Reply to the first post
102

  
103
    TEXT
104
  end
57 105
end
(9-9/15)