diff options
-rw-r--r-- | app/assets/javascripts/application.js | 1 | ||||
-rw-r--r-- | app/assets/javascripts/quote_reply.js | 216 | ||||
-rw-r--r-- | app/assets/javascripts/turndown-7.2.0.min.js | 8 | ||||
-rw-r--r-- | app/controllers/journals_controller.rb | 18 | ||||
-rw-r--r-- | app/controllers/messages_controller.rb | 12 | ||||
-rw-r--r-- | app/helpers/journals_helper.rb | 11 | ||||
-rw-r--r-- | app/helpers/messages_helper.rb | 1 | ||||
-rw-r--r-- | app/views/issues/show.html.erb | 8 | ||||
-rw-r--r-- | app/views/messages/show.html.erb | 27 | ||||
-rw-r--r-- | lib/redmine/quote_reply.rb | 87 | ||||
-rw-r--r-- | test/functional/journals_controller_test.rb | 34 | ||||
-rw-r--r-- | test/functional/messages_controller_test.rb | 35 | ||||
-rw-r--r-- | test/helpers/journals_helper_test.rb | 2 | ||||
-rw-r--r-- | test/system/issues_reply_test.rb | 262 | ||||
-rw-r--r-- | test/system/messages_test.rb | 119 | ||||
-rw-r--r-- | test/unit/lib/redmine/quote_reply_helper_test.rb | 40 |
16 files changed, 830 insertions, 51 deletions
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 82adf53a8..5a01f16c7 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1261,7 +1261,6 @@ function inlineAutoComplete(element) { tribute.attach(element); } - $(document).ready(setupAjaxIndicator); $(document).ready(hideOnLoad); $(document).ready(addFormObserversForDoubleSubmit); diff --git a/app/assets/javascripts/quote_reply.js b/app/assets/javascripts/quote_reply.js new file mode 100644 index 000000000..7649f5125 --- /dev/null +++ b/app/assets/javascripts/quote_reply.js @@ -0,0 +1,216 @@ +function quoteReply(path, selectorForContentElement, textFormatting) { + const contentElement = $(selectorForContentElement).get(0); + const selectedRange = QuoteExtractor.extract(contentElement); + + let formatter; + + if (textFormatting === 'common_mark') { + formatter = new QuoteCommonMarkFormatter(); + } else { + formatter = new QuoteTextFormatter(); + } + + $.ajax({ + url: path, + type: 'post', + data: { quote: formatter.format(selectedRange) } + }); +} + +class QuoteExtractor { + static extract(targetElement) { + return new QuoteExtractor(targetElement).extract(); + } + + constructor(targetElement) { + this.targetElement = targetElement; + this.selection = window.getSelection(); + } + + extract() { + const range = this.retriveSelectedRange(); + + if (!range) { + return null; + } + + if (!this.targetElement.contains(range.startContainer)) { + range.setStartBefore(this.targetElement); + } + if (!this.targetElement.contains(range.endContainer)) { + range.setEndAfter(this.targetElement); + } + + return range; + } + + retriveSelectedRange() { + if (!this.isSelected) { + return null; + } + + // Retrive the first range that intersects with the target element. + // NOTE: Firefox allows to select multiple ranges in the document. + for (let i = 0; i < this.selection.rangeCount; i++) { + let range = this.selection.getRangeAt(i); + if (range.intersectsNode(this.targetElement)) { + return range; + } + } + return null; + } + + get isSelected() { + return this.selection.containsNode(this.targetElement, true); + } +} + +class QuoteTextFormatter { + format(selectedRange) { + if (!selectedRange) { + return null; + } + + const fragment = document.createElement('div'); + fragment.appendChild(selectedRange.cloneContents()); + + // Remove all unnecessary anchor elements + fragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove()); + + const html = this.adjustLineBreaks(fragment.innerHTML); + + const result = document.createElement('div'); + result.innerHTML = html; + + // Replace continuous line breaks with a single line break and remove tab characters + return result.textContent + .trim() + .replace(/\t/g, '') + .replace(/\n+/g, "\n"); + } + + adjustLineBreaks(html) { + return html + .replace(/<\/(h1|h2|h3|h4|div|p|li|tr)>/g, "\n</$1>") + .replace(/<br>/g, "\n") + } +} + +class QuoteCommonMarkFormatter { + format(selectedRange) { + if (!selectedRange) { + return null; + } + + const htmlFragment = this.extractHtmlFragmentFrom(selectedRange); + const preparedHtml = this.prepareHtml(htmlFragment); + + return this.convertHtmlToCommonMark(preparedHtml); + } + + extractHtmlFragmentFrom(range) { + const fragment = document.createElement('div'); + const ancestorNodeName = range.commonAncestorContainer.nodeName; + + if (ancestorNodeName == 'CODE' || ancestorNodeName == '#text') { + fragment.appendChild(this.wrapPreCode(range)); + } else { + fragment.appendChild(range.cloneContents()); + } + + return fragment; + } + + // When only the content within the `<code>` element is selected, + // the HTML within the selection range does not include the `<pre><code>` element itself. + // To create a complete code block, wrap the selected content with the `<pre><code>` tags. + // + // selected contentes => <pre><code class="ruby">selected contents</code></pre> + 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. + // <h1>Title1<a href="#Title" class="wiki-anchor">ΒΆ</a></h1> => <h1>Title1</h1> + htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove()); + + // Convert code highlight blocks to CommonMark format code blocks. + // <code class="ruby" data-language="ruby"> => <code class="language-ruby" data-language="ruby"> + 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<e.length;r++){var i=e[r];if(((e,n,t)=>{var r=e.filter;if("string"==typeof r)return r===n.nodeName.toLowerCase();if(Array.isArray(r))return-1<r.indexOf(n.nodeName.toLowerCase());if("function"==typeof r)return!!r.call(e,n,t);throw new TypeError("`filter` needs to be a string, array, or function")})(i,n,t))return i}}function p(e){var n=e.nextSibling||e.parentNode;return e.parentNode.removeChild(e),n}function h(e,n,t){return e&&e.parentNode===n||t(n)?n.nextSibling||n.parentNode:n.firstChild||n.nextSibling||n.parentNode}t.paragraph={filter:"p",replacement:function(e){return"\n\n"+e+"\n\n"}},t.lineBreak={filter:"br",replacement:function(e,n,t){return t.br+"\n"}},t.heading={filter:["h1","h2","h3","h4","h5","h6"],replacement:function(e,n,t){n=Number(n.nodeName.charAt(1));return"setext"===t.headingStyle&&n<3?"\n\n"+e+"\n"+u(1===n?"=":"-",e.length)+"\n\n":"\n\n"+u("#",n)+" "+e+"\n\n"}},t.blockquote={filter:"blockquote",replacement:function(e){return"\n\n"+(e=(e=e.replace(/^\n+|\n+$/g,"")).replace(/^/gm,"> "))+"\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?"!["+t+"]("+r+(n?' "'+n+'"':"")+")":""}},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<this.array.length;n++)e(this.array[n],n)}};var g,m="undefined"!=typeof window?window:{},A=(()=>{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('<x-turndown id="turndown-root">'+e+"</x-turndown>","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<arguments.length;n++){var t,r=arguments[n];for(t in r)r.hasOwnProperty(t)&&(e[t]=r[t])}return e}({},{rules:t,headingStyle:"setext",hr:"* * *",bulletListMarker:"*",codeBlockStyle:"indented",fence:"```",emDelimiter:"_",strongDelimiter:"**",linkStyle:"inlined",linkReferenceStyle:"full",br:" ",preformattedCode:!1,blankReplacement:function(e,n){return n.isBlock?"\n\n":""},keepReplacement:function(e,n){return n.isBlock?"\n\n"+n.outerHTML+"\n\n":n.outerHTML},defaultReplacement:function(e,n){return n.isBlock?"\n\n"+e+"\n\n":e}},e),this.rules=new l(this.options)}function k(e){var r=this;return T.call(e.childNodes,function(e,n){var t="";return 3===(n=new N(n,r.options)).nodeType?t=n.isCode?n.nodeValue:r.escape(n.nodeValue):1===n.nodeType&&(t=function(e){var n=this.rules.forNode(e),t=k.call(this,e),r=e.flankingWhitespace;(r.leading||r.trailing)&&(t=t.trim());return r.leading+n.replacement(t,e,this.options)+r.trailing}.call(r,n)),b(e,t)},"")}function b(e,n){var t=(e=>{for(var n=e.length;0<n&&"\n"===e[n-1];)n--;return e.substring(0,n)})(e),r=n.replace(/^\n*/,""),e=Math.max(e.length-t.length,n.length-r.length);return t+"\n\n".substring(0,e)+r}return C.prototype={turndown:function(e){if(null==(n=e)||"string"!=typeof n&&(!n.nodeType||1!==n.nodeType&&9!==n.nodeType&&11!==n.nodeType))throw new TypeError(e+" is not a string, or an element/document/fragment node.");var n;return""===e?"":(n=k.call(this,new y(e,this.options)),function(n){var t=this;return this.rules.forEach(function(e){"function"==typeof e.append&&(n=b(n,e.append(t.options)))}),n.replace(/^[\t\r\n]+/,"").replace(/[\t\r\n\s]+$/,"")}.call(this,n))},use:function(e){if(Array.isArray(e))for(var n=0;n<e.length;n++)this.use(e[n]);else{if("function"!=typeof e)throw new TypeError("plugin must be a Function or an Array of Functions");e(this)}return this},addRule:function(e,n){return this.rules.add(e,n),this},keep:function(e){return this.rules.keep(e),this},remove:function(e){return this.rules.remove(e),this},escape:function(e){return R.reduce(function(e,n){return e.replace(n[0],n[1])},e)}},C})(); diff --git a/app/controllers/journals_controller.rb b/app/controllers/journals_controller.rb index cbf545187..dd75b99a5 100644 --- a/app/controllers/journals_controller.rb +++ b/app/controllers/journals_controller.rb @@ -31,6 +31,7 @@ class JournalsController < ApplicationController helper :queries helper :attachments include QueriesHelper + include Redmine::QuoteReply::Builder def index retrieve_query @@ -65,18 +66,11 @@ class JournalsController < ApplicationController def new @journal = Journal.visible.find(params[:journal_id]) if params[:journal_id] - if @journal - user = @journal.user - text = @journal.notes - @content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => 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{<pre>(.*?)</pre>}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{<pre>(.*?)</pre>}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' %> <h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %> @@ -84,11 +88,11 @@ end %> <hr /> <div class="description"> <div class="contextual"> - <%= 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? %> </div> <p><strong><%=l(:field_description)%></strong></p> - <div class="wiki"> + <div id="issue_description_wiki" class="wiki"> <%= textilizable @issue, :description, :attachments => @issue.attachments %> </div> </div> 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) %> <div class="contextual"> <%= 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 @@ <div class="message"> <p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p> -<div class="wiki"> +<div id="message_topic_wiki" class="wiki"> <%= textilizable(@topic, :content) %> </div> <%= link_to_attachments @topic, :author => false, :thumbnails => true %> @@ -42,13 +44,10 @@ <% @replies.each do |message| %> <div class="message reply" id="<%= "message-#{message.id}" %>"> <div class="contextual"> - <%= 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{<pre>(.*?)</pre>}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 |