` element is selected,
+ // the HTML within the selection range does not include the `` element itself.
+ // To create a complete code block, wrap the selected content with the `` tags.
+ //
+ // selected contentes => selected contents
+ wrapPreCode(range) {
+ const rangeAncestor = range.commonAncestorContainer;
+
+ let codeElement = null;
+
+ if (rangeAncestor.nodeName == 'CODE') {
+ codeElement = rangeAncestor;
+ } else {
+ codeElement = rangeAncestor.parentElement.closest('code');
+ }
+
+ if (!codeElement) {
+ return range.cloneContents();
+ }
+
+ const pre = document.createElement('pre');
+ const code = codeElement.cloneNode(false);
+
+ code.appendChild(range.cloneContents());
+ pre.appendChild(code);
+
+ return pre;
+ }
+
+ convertHtmlToCommonMark(html) {
+ const turndownService = new TurndownService({
+ codeBlockStyle: 'fenced',
+ headingStyle: 'atx'
+ });
+
+ turndownService.addRule('del', {
+ filter: ['del'],
+ replacement: content => `~~${content}~~`
+ });
+
+ turndownService.addRule('checkList', {
+ filter: node => {
+ return node.type === 'checkbox' && node.parentNode.nodeName === 'LI';
+ },
+ replacement: (content, node) => {
+ return node.checked ? '[x]' : '[ ]';
+ }
+ });
+
+ // Table does not maintain its original format,
+ // and the text within the table is displayed as it is
+ //
+ // | A | B | C |
+ // |---|---|---|
+ // | 1 | 2 | 3 |
+ // =>
+ // A B C
+ // 1 2 3
+ turndownService.addRule('table', {
+ filter: ['td', 'th'],
+ replacement: (content, node) => {
+ const separator = node.parentElement.lastElementChild === node ? '' : ' ';
+ return content + separator;
+ }
+ });
+ turndownService.addRule('tableHeading', {
+ filter: ['thead', 'tbody', 'tfoot', 'tr'],
+ replacement: (content, _node) => content
+ });
+ turndownService.addRule('tableRow', {
+ filter: ['tr'],
+ replacement: (content, _node) => {
+ return content + '\n'
+ }
+ });
+
+ return turndownService.turndown(html);
+ }
+
+ prepareHtml(htmlFragment) {
+ // Remove all anchor elements.
+ // Title1ΒΆ
=> Title1
+ htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
+
+ // Convert code highlight blocks to CommonMark format code blocks.
+ // =>
+ htmlFragment.querySelectorAll('code[data-language]').forEach(e => {
+ e.classList.replace(e.dataset['language'], 'language-' + e.dataset['language'])
+ });
+
+ return htmlFragment.innerHTML;
+ }
+}
diff --git a/app/assets/javascripts/turndown-7.2.0.min.js b/app/assets/javascripts/turndown-7.2.0.min.js
new file mode 100644
index 000000000..f3fb4b1e6
--- /dev/null
+++ b/app/assets/javascripts/turndown-7.2.0.min.js
@@ -0,0 +1,8 @@
+/*
+ * Turndown v7.2.0
+ * https://github.com/mixmark-io/turndown
+ * Copyright (c) 2017 Dom Christie
+ * Released under the MIT license
+ * https://github.com/mixmark-io/turndown/blob/master/LICENSE
+ */
+var TurndownService=(()=>{function u(e,n){return Array(n+1).join(e)}var n=["ADDRESS","ARTICLE","ASIDE","AUDIO","BLOCKQUOTE","BODY","CANVAS","CENTER","DD","DIR","DIV","DL","DT","FIELDSET","FIGCAPTION","FIGURE","FOOTER","FORM","FRAMESET","H1","H2","H3","H4","H5","H6","HEADER","HGROUP","HR","HTML","ISINDEX","LI","MAIN","MENU","NAV","NOFRAMES","NOSCRIPT","OL","OUTPUT","P","PRE","SECTION","TABLE","TBODY","TD","TFOOT","TH","THEAD","TR","UL"];function f(e){return o(e,n)}var r=["AREA","BASE","BR","COL","COMMAND","EMBED","HR","IMG","INPUT","KEYGEN","LINK","META","PARAM","SOURCE","TRACK","WBR"];function d(e){return o(e,r)}var i=["A","TABLE","THEAD","TBODY","TFOOT","TH","TD","IFRAME","SCRIPT","AUDIO","VIDEO"];function o(e,n){return 0<=n.indexOf(e.nodeName)}function a(n,e){return n.getElementsByTagName&&e.some(function(e){return n.getElementsByTagName(e).length})}var t={};function c(e){return e?e.replace(/(\n+\s*)+/g,"\n"):""}function l(e){for(var n in this.options=e,this._keep=[],this._remove=[],this.blankRule={replacement:e.blankReplacement},this.keepReplacement=e.keepReplacement,this.defaultRule={replacement:e.defaultReplacement},this.array=[],e.rules)this.array.push(e.rules[n])}function s(e,n,t){for(var r=0;r{var r=e.filter;if("string"==typeof r)return r===n.nodeName.toLowerCase();if(Array.isArray(r))return-1 "))+"\n\n"}},t.list={filter:["ul","ol"],replacement:function(e,n){var t=n.parentNode;return"LI"===t.nodeName&&t.lastElementChild===n?"\n"+e:"\n\n"+e+"\n\n"}},t.listItem={filter:"li",replacement:function(e,n,t){e=e.replace(/^\n+/,"").replace(/\n+$/,"\n").replace(/\n/gm,"\n ");var r,t=t.bulletListMarker+" ",i=n.parentNode;return"OL"===i.nodeName&&(r=i.getAttribute("start"),i=Array.prototype.indexOf.call(i.children,n),t=(r?Number(r)+i:i+1)+". "),t+e+(n.nextSibling&&!/\n$/.test(e)?"\n":"")}},t.indentedCodeBlock={filter:function(e,n){return"indented"===n.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,n,t){return"\n\n "+n.firstChild.textContent.replace(/\n/g,"\n ")+"\n\n"}},t.fencedCodeBlock={filter:function(e,n){return"fenced"===n.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,n,t){for(var r,i=((n.firstChild.getAttribute("class")||"").match(/language-(\S+)/)||[null,""])[1],o=n.firstChild.textContent,n=t.fence.charAt(0),a=3,l=new RegExp("^"+n+"{3,}","gm");r=l.exec(o);)r[0].length>=a&&(a=r[0].length+1);t=u(n,a);return"\n\n"+t+i+"\n"+o.replace(/\n$/,"")+"\n"+t+"\n\n"}},t.horizontalRule={filter:"hr",replacement:function(e,n,t){return"\n\n"+t.hr+"\n\n"}},t.inlineLink={filter:function(e,n){return"inlined"===n.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,n){var t=(t=n.getAttribute("href"))&&t.replace(/([()])/g,"\\$1"),n=c(n.getAttribute("title"));return"["+e+"]("+t+(n=n&&' "'+n.replace(/"/g,'\\"')+'"')+")"}},t.referenceLink={filter:function(e,n){return"referenced"===n.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,n,t){var r=n.getAttribute("href"),i=(i=c(n.getAttribute("title")))&&' "'+i+'"';switch(t.linkReferenceStyle){case"collapsed":a="["+e+"][]",l="["+e+"]: "+r+i;break;case"shortcut":a="["+e+"]",l="["+e+"]: "+r+i;break;default:var o=this.references.length+1,a="["+e+"]["+o+"]",l="["+o+"]: "+r+i}return this.references.push(l),a},references:[],append:function(e){var n="";return this.references.length&&(n="\n\n"+this.references.join("\n")+"\n\n",this.references=[]),n}},t.emphasis={filter:["em","i"],replacement:function(e,n,t){return e.trim()?t.emDelimiter+e+t.emDelimiter:""}},t.strong={filter:["strong","b"],replacement:function(e,n,t){return e.trim()?t.strongDelimiter+e+t.strongDelimiter:""}},t.code={filter:function(e){var n=e.previousSibling||e.nextSibling,n="PRE"===e.parentNode.nodeName&&!n;return"CODE"===e.nodeName&&!n},replacement:function(e){if(!e)return"";e=e.replace(/\r?\n|\r/g," ");for(var n=/^`|^ .*?[^ ].* $|`$/.test(e)?" ":"",t="`",r=e.match(/`+/gm)||[];-1!==r.indexOf(t);)t+="`";return t+n+e+n+t}},t.image={filter:"img",replacement:function(e,n){var t=c(n.getAttribute("alt")),r=n.getAttribute("src")||"",n=c(n.getAttribute("title"));return r?"+")":""}},l.prototype={add:function(e,n){this.array.unshift(n)},keep:function(e){this._keep.unshift({filter:e,replacement:this.keepReplacement})},remove:function(e){this._remove.unshift({filter:e,replacement:function(){return""}})},forNode:function(e){return e.isBlank?this.blankRule:s(this.array,e,this.options)||s(this._keep,e,this.options)||s(this._remove,e,this.options)||this.defaultRule},forEach:function(e){for(var n=0;n{var e=m.DOMParser,n=!1;try{(new e).parseFromString("","text/html")&&(n=!0)}catch(e){}return n})()?m.DOMParser:((()=>{var n=!1;try{document.implementation.createHTMLDocument("").open()}catch(e){m.ActiveXObject&&(n=!0)}return n})()?e.prototype.parseFromString=function(e){var n=new window.ActiveXObject("htmlfile");return n.designMode="on",n.open(),n.write(e),n.close(),n}:e.prototype.parseFromString=function(e){var n=document.implementation.createHTMLDocument("");return n.open(),n.write(e),n.close(),n},e);function e(){}function y(e,n){var n={element:e="string"==typeof e?(g=g||new A).parseFromString(''+e+"","text/html").getElementById("turndown-root"):e.cloneNode(!0),isBlock:f,isVoid:d,isPre:n.preformattedCode?v:null},t=n.element,r=n.isBlock,i=n.isVoid,o=n.isPre||function(e){return"PRE"===e.nodeName};if(t.firstChild&&!o(t)){for(var a=null,l=!1,u=h(s=null,t,o);u!==t;){if(3===u.nodeType||4===u.nodeType){var c=u.data.replace(/[ \r\n\t]+/g," ");if(!(c=a&&!/ $/.test(a.data)||l||" "!==c[0]?c:c.substr(1))){u=p(u);continue}u.data=c,a=u}else{if(1!==u.nodeType){u=p(u);continue}r(u)||"BR"===u.nodeName?(a&&(a.data=a.data.replace(/ $/,"")),a=null,l=!1):i(u)||o(u)?l=!(a=null):a&&(l=!1)}var c=h(s,u,o),s=u,u=c}a&&(a.data=a.data.replace(/ $/,""),a.data||p(a))}return e}function v(e){return"PRE"===e.nodeName||"CODE"===e.nodeName}function N(e,n){var t;return e.isBlock=f(e),e.isCode="CODE"===e.nodeName||e.parentNode.isCode,e.isBlank=!d(t=e)&&!(e=>o(e,i))(t)&&/^\s*$/i.test(t.textContent)&&!(e=>a(e,r))(t)&&!(e=>a(e,i))(t),e.flankingWhitespace=((e,n)=>{var t;return e.isBlock||n.preformattedCode&&e.isCode?{leading:"",trailing:""}:((t=(e=>({leading:(e=e.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/))[1],leadingAscii:e[2],leadingNonAscii:e[3],trailing:e[4],trailingNonAscii:e[5],trailingAscii:e[6]}))(e.textContent)).leadingAscii&&E("left",e,n)&&(t.leading=t.leadingNonAscii),t.trailingAscii&&E("right",e,n)&&(t.trailing=t.trailingNonAscii),{leading:t.leading,trailing:t.trailing})})(e,n),e}function E(e,n,t){var r,i,e="left"===e?(r=n.previousSibling,/ $/):(r=n.nextSibling,/^ /);return r&&(3===r.nodeType?i=e.test(r.nodeValue):t.preformattedCode&&"CODE"===r.nodeName?i=!1:1!==r.nodeType||f(r)||(i=e.test(r.textContent))),i}var T=Array.prototype.reduce,R=[[/\\/g,"\\\\"],[/\*/g,"\\*"],[/^-/g,"\\-"],[/^\+ /g,"\\+ "],[/^(=+)/g,"\\$1"],[/^(#{1,6}) /g,"\\$1 "],[/`/g,"\\`"],[/^~~~/g,"\\~~~"],[/\[/g,"\\["],[/\]/g,"\\]"],[/^>/g,"\\>"],[/_/g,"\\_"],[/^(\d+)\. /g,"$1\\. "]];function C(e){if(!(this instanceof C))return new C(e);this.options=function(e){for(var n=1;n{for(var n=e.length;0 user, :link => "#note-#{params[:journal_indice]}"})}\n> "
- else
- user = @issue.author
- text = @issue.description
- @content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
- end
- # Replaces pre blocks with [...]
- text = text.to_s.strip.gsub(%r{(.*?)
}m, '[...]')
- @content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
+ @content = if @journal
+ quote_issue_journal(@journal, indice: params[:journal_indice], partial_quote: params[:quote])
+ else
+ quote_issue(@issue, partial_quote: params[:quote])
+ end
rescue ActiveRecord::RecordNotFound
render_404
end
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
index 5050d94a7..5159bf540 100644
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb
@@ -29,6 +29,7 @@ class MessagesController < ApplicationController
helper :watchers
helper :attachments
include AttachmentsHelper
+ include Redmine::QuoteReply::Builder
REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE)
@@ -119,12 +120,11 @@ class MessagesController < ApplicationController
@subject = @message.subject
@subject = "RE: #{@subject}" unless @subject.starts_with?('RE:')
- if @message.root == @message
- @content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> "
- else
- @content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => @message.author, :link => "message##{@message.id}"})}\n> "
- end
- @content << @message.content.to_s.strip.gsub(%r{(.*?)
}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
+ @content = if @message.root == @message
+ quote_root_message(@message, partial_quote: params[:quote])
+ else
+ quote_message(@message, partial_quote: params[:quote])
+ end
respond_to do |format|
format.html { render_404 }
diff --git a/app/helpers/journals_helper.rb b/app/helpers/journals_helper.rb
index e51207e0e..e4c23995e 100644
--- a/app/helpers/journals_helper.rb
+++ b/app/helpers/journals_helper.rb
@@ -18,6 +18,8 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module JournalsHelper
+ include Redmine::QuoteReply::Helper
+
# Returns the attachments of a journal that are displayed as thumbnails
def journal_thumbnail_attachments(journal)
journal.attachments.select(&:thumbnailable?)
@@ -40,13 +42,8 @@ module JournalsHelper
if journal.notes.present?
if options[:reply_links]
- links << link_to(icon_with_label('comment', l(:button_quote)),
- quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice),
- :remote => true,
- :method => 'post',
- :title => l(:button_quote),
- :class => 'icon-only icon-comment'
- )
+ url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
+ links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
end
if journal.editable_by?(User.current)
links << link_to(icon_with_label('edit', l(:button_edit)),
diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb
index 24218e46e..fd9ba3bcb 100644
--- a/app/helpers/messages_helper.rb
+++ b/app/helpers/messages_helper.rb
@@ -18,4 +18,5 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module MessagesHelper
+ include Redmine::QuoteReply::Helper
end
diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb
index 13c1bc632..8f732032a 100644
--- a/app/views/issues/show.html.erb
+++ b/app/views/issues/show.html.erb
@@ -1,3 +1,7 @@
+<% content_for :header_tags do %>
+ <%= javascripts_for_quote_reply_include_tag %>
+<% end %>
+
<%= render :partial => 'action_menu' %>
<%= issue_heading(@issue) %>
<%= issue_status_type_badge(@issue.status) %>
@@ -84,11 +88,11 @@ end %>
- <%= link_to icon_with_label('comment', l(:button_quote)), quoted_issue_path(@issue), :remote => true, :method => 'post', :class => 'icon icon-comment ' if @issue.notes_addable? %>
+ <%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>
<%=l(:field_description)%>
-
+
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb
index 64c69a0fe..87355e65d 100644
--- a/app/views/messages/show.html.erb
+++ b/app/views/messages/show.html.erb
@@ -1,13 +1,15 @@
+<% content_for :header_tags do %>
+ <%= javascripts_for_quote_reply_include_tag %>
+<% end %>
+
<%= board_breadcrumb(@message) %>
<%= watcher_link(@topic, User.current) %>
- <%= link_to(
- icon_with_label('comment', l(:button_quote)),
- {:action => 'quote', :id => @topic},
- :method => 'get',
- :class => 'icon icon-comment',
- :remote => true) if !@topic.locked? && authorize_for('messages', 'reply') %>
+ <%= quote_reply(
+ url_for(:action => 'quote', :id => @topic, :format => 'js'),
+ '#message_topic_wiki'
+ ) if !@topic.locked? && authorize_for('messages', 'reply') %>
<%= link_to(
icon_with_label('edit', l(:button_edit)),
{:action => 'edit', :id => @topic},
@@ -26,7 +28,7 @@
<%= authoring @topic.created_on, @topic.author %>
-
+
<%= textilizable(@topic, :content) %>
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
@@ -42,13 +44,10 @@
<% @replies.each do |message| %>
">
- <%= link_to(
- icon_with_label('comment', l(:button_quote), icon_only: true),
- {:action => 'quote', :id => message},
- :remote => true,
- :method => 'get',
- :title => l(:button_quote),
- :class => 'icon icon-comment'
+ <%= quote_reply(
+ url_for(:action => 'quote', :id => message, :format => 'js'),
+ "#message-#{message.id} .wiki",
+ icon_only: true
) if !@topic.locked? && authorize_for('messages', 'reply') %>
<%= link_to(
icon_with_label('edit', l(:button_edit), icon_only: true),
diff --git a/lib/redmine/quote_reply.rb b/lib/redmine/quote_reply.rb
new file mode 100644
index 000000000..4bc83db70
--- /dev/null
+++ b/lib/redmine/quote_reply.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+module Redmine
+ module QuoteReply
+ module Helper
+ def javascripts_for_quote_reply_include_tag
+ javascript_include_tag 'turndown-7.2.0.min', 'quote_reply'
+ end
+
+ def quote_reply(url, selector_for_content, icon_only: false)
+ quote_reply_function = "quoteReply('#{j url}', '#{j selector_for_content}', '#{j Setting.text_formatting}')"
+
+ html_options = { class: 'icon icon-comment' }
+ html_options[:title] = l(:button_quote) if icon_only
+
+ link_to_function(
+ icon_with_label('comment', l(:button_quote), icon_only: icon_only),
+ quote_reply_function,
+ html_options
+ )
+ end
+ end
+
+ module Builder
+ def quote_issue(issue, partial_quote: nil)
+ user = issue.author
+
+ build_quote(
+ "#{ll(Setting.default_language, :text_user_wrote, user)}\n> ",
+ issue.description,
+ partial_quote
+ )
+ end
+
+ def quote_issue_journal(journal, indice:, partial_quote: nil)
+ user = journal.user
+
+ build_quote(
+ "#{ll(Setting.default_language, :text_user_wrote_in, {value: journal.user, link: "#note-#{indice}"})}\n> ",
+ journal.notes,
+ partial_quote
+ )
+ end
+
+ def quote_root_message(message, partial_quote: nil)
+ build_quote(
+ "#{ll(Setting.default_language, :text_user_wrote, message.author)}\n> ",
+ message.content,
+ partial_quote
+ )
+ end
+
+ def quote_message(message, partial_quote: nil)
+ build_quote(
+ "#{ll(Setting.default_language, :text_user_wrote_in, {value: message.author, link: "message##{message.id}"})}\n> ",
+ message.content,
+ partial_quote
+ )
+ end
+
+ private
+
+ def build_quote(quote_header, text, partial_quote = nil)
+ quote_text = partial_quote.presence || text.to_s.strip.gsub(%r{
(.*?)
}m, '[...]')
+
+ "#{quote_header}#{quote_text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"}"
+ end
+ end
+ end
+end
diff --git a/test/functional/journals_controller_test.rb b/test/functional/journals_controller_test.rb
index e02b29ca4..26419e33b 100644
--- a/test/functional/journals_controller_test.rb
+++ b/test/functional/journals_controller_test.rb
@@ -168,7 +168,7 @@ class JournalsControllerTest < Redmine::ControllerTest
def test_reply_to_issue
@request.session[:user_id] = 2
- get(:new, :params => {:id => 6}, :xhr => true)
+ post(:new, :params => {:id => 6}, :xhr => true)
assert_response :success
assert_equal 'text/javascript', response.media_type
@@ -177,13 +177,13 @@ class JournalsControllerTest < Redmine::ControllerTest
def test_reply_to_issue_without_permission
@request.session[:user_id] = 7
- get(:new, :params => {:id => 6}, :xhr => true)
+ post(:new, :params => {:id => 6}, :xhr => true)
assert_response :forbidden
end
def test_reply_to_note
@request.session[:user_id] = 2
- get(
+ post(
:new,
:params => {
:id => 6,
@@ -202,7 +202,7 @@ class JournalsControllerTest < Redmine::ControllerTest
journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true)
@request.session[:user_id] = 2
- get(
+ post(
:new,
:params => {
:id => 2,
@@ -215,7 +215,7 @@ class JournalsControllerTest < Redmine::ControllerTest
assert_include '> Privates notes', response.body
Role.find(1).remove_permission! :view_private_notes
- get(
+ post(
:new,
:params => {
:id => 2,
@@ -226,6 +226,30 @@ class JournalsControllerTest < Redmine::ControllerTest
assert_response :not_found
end
+ def test_reply_to_issue_with_partial_quote
+ @request.session[:user_id] = 2
+
+ params = { id: 6, quote: 'a private subproject of cookbook' }
+ post :new, params: params, xhr: true
+
+ assert_response :success
+ assert_equal 'text/javascript', response.media_type
+ assert_include 'John Smith wrote:', response.body
+ assert_include '> a private subproject of cookbook', response.body
+ end
+
+ def test_reply_to_note_with_partial_quote
+ @request.session[:user_id] = 2
+
+ params = { id: 6, journal_id: 4, journal_indice: 1, quote: 'a private version' }
+ post :new, params: params, xhr: true
+
+ assert_response :success
+ assert_equal 'text/javascript', response.media_type
+ assert_include 'Redmine Admin wrote in #note-1:', response.body
+ assert_include '> a private version', response.body
+ end
+
def test_edit_xhr
@request.session[:user_id] = 1
get(:edit, :params => {:id => 2}, :xhr => true)
diff --git a/test/functional/messages_controller_test.rb b/test/functional/messages_controller_test.rb
index db91082ff..c0723643b 100644
--- a/test/functional/messages_controller_test.rb
+++ b/test/functional/messages_controller_test.rb
@@ -288,7 +288,7 @@ class MessagesControllerTest < Redmine::ControllerTest
def test_quote_if_message_is_root
@request.session[:user_id] = 2
- get(
+ post(
:quote,
:params => {
:board_id => 1,
@@ -306,7 +306,7 @@ class MessagesControllerTest < Redmine::ControllerTest
def test_quote_if_message_is_not_root
@request.session[:user_id] = 2
- get(
+ post(
:quote,
:params => {
:board_id => 1,
@@ -322,9 +322,38 @@ class MessagesControllerTest < Redmine::ControllerTest
assert_include '> An other reply', response.body
end
+ def test_quote_with_partial_quote_if_message_is_root
+ @request.session[:user_id] = 2
+
+ params = { board_id: 1, id: 1,
+ quote: "the very first post\nin the forum" }
+ post :quote, params: params, xhr: true
+
+ assert_response :success
+ assert_equal 'text/javascript', response.media_type
+
+ assert_include 'RE: First post', response.body
+ assert_include "Redmine Admin wrote:", response.body
+ assert_include '> the very first post\n> in the forum', response.body
+ end
+
+ def test_quote_with_partial_quote_if_message_is_not_root
+ @request.session[:user_id] = 2
+
+ params = { board_id: 1, id: 3, quote: 'other reply' }
+ post :quote, params: params, xhr: true
+
+ assert_response :success
+ assert_equal 'text/javascript', response.media_type
+
+ assert_include 'RE: First post', response.body
+ assert_include 'John Smith wrote in message#3:', response.body
+ assert_include '> other reply', response.body
+ end
+
def test_quote_as_html_should_respond_with_404
@request.session[:user_id] = 2
- get(
+ post(
:quote,
:params => {
:board_id => 1,
diff --git a/test/helpers/journals_helper_test.rb b/test/helpers/journals_helper_test.rb
index 55fb1260a..b9d73ef64 100644
--- a/test/helpers/journals_helper_test.rb
+++ b/test/helpers/journals_helper_test.rb
@@ -57,7 +57,7 @@ class JournalsHelperTest < Redmine::HelperTest
journals = issue.visible_journals_with_index # add indice
journal_actions = render_journal_actions(issue, journals.first, {reply_links: true})
- assert_select_in journal_actions, 'a[title=?][class="icon-only icon-comment"]', 'Quote'
+ assert_select_in journal_actions, 'a[title=?][class="icon icon-comment"]', 'Quote'
assert_select_in journal_actions, 'a[title=?][class="icon-only icon-edit"]', 'Edit'
assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-del"]'
assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-copy-link"]'
diff --git a/test/system/issues_reply_test.rb b/test/system/issues_reply_test.rb
new file mode 100644
index 000000000..7ab5e21c2
--- /dev/null
+++ b/test/system/issues_reply_test.rb
@@ -0,0 +1,262 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../application_system_test_case'
+
+class IssuesReplyTest < ApplicationSystemTestCase
+ fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
+ :trackers, :projects_trackers, :enabled_modules,
+ :issue_statuses, :issues, :issue_categories,
+ :enumerations, :custom_fields, :custom_values, :custom_fields_trackers,
+ :watchers, :journals, :journal_details, :versions,
+ :workflows
+
+ def test_reply_to_issue
+ with_text_formatting 'common_mark' do
+ within '.issue.details' do
+ click_link 'Quote'
+ end
+
+ # Select the other than the issue description element.
+ page.execute_script <<-JS
+ const range = document.createRange();
+ // Select "Description" text.
+ range.selectNodeContents(document.querySelector('.description > p'))
+
+ window.getSelection().addRange(range);
+ JS
+
+ assert_field 'issue_notes', with: <<~TEXT
+ John Smith wrote:
+ > Unable to print recipes
+
+ TEXT
+ assert_selector :css, '#issue_notes:focus'
+ end
+ end
+
+ def test_reply_to_note
+ with_text_formatting 'textile' do
+ within '#change-1' do
+ click_link 'Quote'
+ end
+
+ assert_field 'issue_notes', with: <<~TEXT
+ Redmine Admin wrote in #note-1:
+ > Journal notes
+
+ TEXT
+ assert_selector :css, '#issue_notes:focus'
+ end
+ end
+
+ def test_reply_to_issue_with_partial_quote
+ with_text_formatting 'common_mark' do
+ assert_text 'Unable to print recipes'
+
+ # Select only the "print" text from the text "Unable to print recipes" in the description.
+ page.execute_script <<-JS
+ const range = document.createRange();
+ const wiki = document.querySelector('#issue_description_wiki > p').childNodes[0];
+ range.setStart(wiki, 10);
+ range.setEnd(wiki, 15);
+
+ window.getSelection().addRange(range);
+ JS
+
+ within '.issue.details' do
+ click_link 'Quote'
+ end
+
+ assert_field 'issue_notes', with: <<~TEXT
+ John Smith wrote:
+ > print
+
+ TEXT
+ assert_selector :css, '#issue_notes:focus'
+ end
+ end
+
+ def test_reply_to_note_with_partial_quote
+ with_text_formatting 'textile' do
+ assert_text 'Journal notes'
+
+ # Select the entire details of the note#1 and the part of the note#1's text.
+ page.execute_script <<-JS
+ const range = document.createRange();
+ range.setStartBefore(document.querySelector('#change-1 .details'));
+ // Select only the text "Journal" from the text "Journal notes" in the note-1.
+ range.setEnd(document.querySelector('#change-1 .wiki > p').childNodes[0], 7);
+
+ window.getSelection().addRange(range);
+ JS
+
+ within '#change-1' do
+ click_link 'Quote'
+ end
+
+ assert_field 'issue_notes', with: <<~TEXT
+ Redmine Admin wrote in #note-1:
+ > Journal
+
+ TEXT
+ assert_selector :css, '#issue_notes:focus'
+ end
+ end
+
+ def test_partial_quotes_should_be_quoted_in_plain_text_when_text_format_is_textile
+ issues(:issues_001).update!(description: <<~DESC)
+ # "Redmine":https://redmine.org is
+ # a *flexible* project management
+ # web application.
+ DESC
+
+ with_text_formatting 'textile' do
+ assert_text /a flexible project management/
+
+ # Select the entire description of the issue.
+ page.execute_script <<-JS
+ const range = document.createRange();
+ range.selectNodeContents(document.querySelector('#issue_description_wiki'))
+ window.getSelection().addRange(range);
+ JS
+
+ within '.issue.details' do
+ click_link 'Quote'
+ end
+
+ expected_value = [
+ 'John Smith wrote:',
+ '> Redmine is',
+ '> a flexible project management',
+ '> web application.',
+ '',
+ ''
+ ]
+ assert_equal expected_value.join("\n"), find_field('issue_notes').value
+ end
+ end
+
+ def test_partial_quotes_should_be_quoted_in_common_mark_format_when_text_format_is_common_mark
+ issues(:issues_001).update!(description: <<~DESC)
+ # Title1
+ [Redmine](https://redmine.org) is a **flexible** project management web application.
+
+ ## Title2
+ * List1
+ * List1-1
+ * List2
+
+ 1. Number1
+ 1. Number2
+
+ ### Title3
+ ```ruby
+ puts "Hello, world!"
+ ```
+ ```
+ $ bin/rails db:migrate
+ ```
+
+ | Subject1 | Subject2 |
+ | -------- | -------- |
+ | ~~cell1~~| **cell2**|
+
+ * [ ] Checklist1
+ * [x] Checklist2
+
+ [[WikiPage]]
+ Issue #14
+ Issue ##2
+
+ Redmine is `a flexible` project management
+
+ web application.
+ DESC
+
+ with_text_formatting 'common_mark' do
+ assert_text /Title1/
+
+ # Select the entire description of the issue.
+ page.execute_script <<-JS
+ const range = document.createRange();
+ range.selectNodeContents(document.querySelector('#issue_description_wiki'))
+ window.getSelection().addRange(range);
+ JS
+
+ within '.issue.details' do
+ click_link 'Quote'
+ end
+
+ expected_value = [
+ 'John Smith wrote:',
+ '> # Title1',
+ '> ',
+ '> [Redmine](https://redmine.org) is a **flexible** project management web application.',
+ '> ',
+ '> ## Title2',
+ '> ',
+ '> * List1',
+ '> * List1-1',
+ '> * List2',
+ '> ',
+ '> 1. Number1',
+ '> 2. Number2',
+ '> ',
+ '> ### Title3',
+ '> ',
+ '> ```ruby',
+ '> puts "Hello, world!"',
+ '> ```',
+ '> ',
+ '> ```',
+ '> $ bin/rails db:migrate',
+ '> ```',
+ '> ',
+ '> Subject1 Subject2',
+ '> ~~cell1~~ **cell2**',
+ '> ',
+ '> * [ ] Checklist1',
+ '> * [x] Checklist2',
+ '> ',
+ '> [WikiPage](/projects/ecookbook/wiki/WikiPage) ',
+ '> Issue [#14](/issues/14 "Bug: Private issue on public project (New)") ',
+ '> Issue [Feature request #2: Add ingredients categories](/issues/2 "Status: Assigned")',
+ '> ',
+ '> Redmine is `a flexible` project management',
+ '> ',
+ '> web application.',
+ '',
+ ''
+ ]
+ assert_equal expected_value.join("\n"), find_field('issue_notes').value
+ end
+ end
+
+ private
+
+ def with_text_formatting(format)
+ with_settings text_formatting: format do
+ log_user('jsmith', 'jsmith')
+ visit '/issues/1'
+
+ yield
+ end
+ end
+end
diff --git a/test/system/messages_test.rb b/test/system/messages_test.rb
new file mode 100644
index 000000000..ff5e48cd4
--- /dev/null
+++ b/test/system/messages_test.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../application_system_test_case'
+
+class MessagesTest < ApplicationSystemTestCase
+ fixtures :projects, :users, :roles, :members, :member_roles,
+ :enabled_modules, :enumerations,
+ :custom_fields, :custom_values, :custom_fields_trackers,
+ :watchers, :boards, :messages
+
+ def test_reply_to_topic_message
+ with_text_formatting 'common_mark' do
+ within '#content > .contextual' do
+ click_link 'Quote'
+ end
+
+ assert_field 'message_content', with: <<~TEXT
+ Redmine Admin wrote:
+ > This is the very first post
+ > in the forum
+
+ TEXT
+ end
+ end
+
+ def test_reply_to_message
+ with_text_formatting 'textile' do
+ within '#message-2' do
+ click_link 'Quote'
+ end
+
+ assert_field 'message_content', with: <<~TEXT
+ Redmine Admin wrote in message#2:
+ > Reply to the first post
+
+ TEXT
+ end
+ end
+
+ def test_reply_to_topic_message_with_partial_quote
+ with_text_formatting 'textile' do
+ assert_text /This is the very first post/
+
+ # Select the part of the topic message through the entire text of the attachment below it.
+ page.execute_script <<-'JS'
+ const range = document.createRange();
+ const message = document.querySelector('#message_topic_wiki');
+ // Select only the text "in the forum" from the text "This is the very first post\nin the forum".
+ range.setStartBefore(message.querySelector('p').childNodes[2]);
+ range.setEndAfter(message.parentNode.querySelector('.attachments'));
+
+ window.getSelection().addRange(range);
+ JS
+
+ within '#content > .contextual' do
+ click_link 'Quote'
+ end
+
+ assert_field 'message_content', with: <<~TEXT
+ Redmine Admin wrote:
+ > in the forum
+
+ TEXT
+ end
+ end
+
+ def test_reply_to_message_with_partial_quote
+ with_text_formatting 'common_mark' do
+ assert_text 'Reply to the first post'
+
+ # Select the entire message, including the subject and headers of messages #2 and #3.
+ page.execute_script <<-JS
+ const range = document.createRange();
+ range.setStartBefore(document.querySelector('#message-2'));
+ range.setEndAfter(document.querySelector('#message-3'));
+
+ window.getSelection().addRange(range);
+ JS
+
+ within '#message-2' do
+ click_link 'Quote'
+ end
+
+ assert_field 'message_content', with: <<~TEXT
+ Redmine Admin wrote in message#2:
+ > Reply to the first post
+
+ TEXT
+ end
+ end
+
+ private
+
+ def with_text_formatting(format)
+ with_settings text_formatting: format do
+ log_user('jsmith', 'jsmith')
+ visit '/boards/1/topics/1'
+
+ yield
+ end
+ end
+end
diff --git a/test/unit/lib/redmine/quote_reply_helper_test.rb b/test/unit/lib/redmine/quote_reply_helper_test.rb
new file mode 100644
index 000000000..f3c9c110b
--- /dev/null
+++ b/test/unit/lib/redmine/quote_reply_helper_test.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+class QuoteReplyHelperTest < ActionView::TestCase
+ include ERB::Util
+ include Redmine::QuoteReply::Helper
+
+ fixtures :issues
+
+ def test_quote_reply
+ url = quoted_issue_path(issues(:issues_001))
+
+ a_tag = quote_reply(url, '#issue_description_wiki')
+ assert_includes a_tag, %|onclick="#{h "quoteReply('/issues/1/quoted', '#issue_description_wiki', 'common_mark'); return false;"}"|
+ assert_includes a_tag, %|class="icon icon-comment"|
+ assert_not_includes a_tag, 'title='
+
+ # When icon_only is true
+ a_tag = quote_reply(url, '#issue_description_wiki', icon_only: true)
+ assert_includes a_tag, %|title="Quote"|
+ end
+end