Feature #41294 » 0003-Add-the-ability-to-quote-partial-text.patch
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 |
|
|
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 |