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 |