From 330672f303a4a0bda8cf7b1e6e3476aab8c8ed1c Mon Sep 17 00:00:00 2001 From: Katsuya Hidaka Date: Sat, 21 Sep 2024 04:05:34 +0900 Subject: [PATCH 7/7] Allow replying qith quotes in CommonMark format --- app/assets/javascripts/quote_reply.js | 176 +++++++++++- app/assets/javascripts/turndown-7.2.0.min.js | 8 + app/views/issues/show.html.erb | 2 +- app/views/messages/show.html.erb | 2 +- lib/redmine/quote_reply.rb | 6 +- test/system/issues_reply_test.rb | 265 ++++++++++++++---- test/system/messages_test.rb | 116 ++++---- .../lib/redmine/quote_reply_helper_test.rb | 2 +- 8 files changed, 449 insertions(+), 128 deletions(-) create mode 100644 app/assets/javascripts/turndown-7.2.0.min.js diff --git a/app/assets/javascripts/quote_reply.js b/app/assets/javascripts/quote_reply.js index 6238395dc..95cf2c433 100644 --- a/app/assets/javascripts/quote_reply.js +++ b/app/assets/javascripts/quote_reply.js @@ -1,11 +1,19 @@ -function quoteReply(path, selectorForContentElement) { +function quoteReply(path, selectorForContentElement, textFormatting) { const contentElement = $(selectorForContentElement).get(0); - const quote = QuoteExtractor.extract(contentElement); + 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: quote } + data: { quote: formatter.format(selectedRange) } }); } @@ -20,7 +28,7 @@ class QuoteExtractor { } extract() { - const range = this.selectedRange; + const range = this.retriveSelectedRange(); if (!range) { return null; @@ -33,14 +41,10 @@ class QuoteExtractor { range.setEndAfter(this.targetElement); } - return this.formatRange(range); + return range; } - formatRange(range) { - return range.toString().trim(); - } - - get selectedRange() { + retriveSelectedRange() { if (!this.isSelected) { return null; } @@ -60,3 +64,155 @@ class QuoteExtractor { 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()); + + // Adjust line breaks when output as text + 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") + .replace(/
/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 `` 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 elements are not formatted, and the within the table is output as is. + // + // | A | B | C | + // |---|---|---| + // | 1 | 2 | 3 | + // => + // A B C + // 1 2 3 + turndownService.addRule('table', { + filter: (node, options) => { + return node.nodeName === 'TD' || node.nodeName === '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 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?"!["+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{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 - <%= javascript_for_quote_reply_include_tag %> + <%= javascripts_for_quote_reply_include_tag %> <% end %> <%= render :partial => 'action_menu' %> diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb index e21d1686c..87355e65d 100644 --- a/app/views/messages/show.html.erb +++ b/app/views/messages/show.html.erb @@ -1,5 +1,5 @@ <% content_for :header_tags do %> - <%= javascript_for_quote_reply_include_tag %> + <%= javascripts_for_quote_reply_include_tag %> <% end %> <%= board_breadcrumb(@message) %> diff --git a/lib/redmine/quote_reply.rb b/lib/redmine/quote_reply.rb index 1f0ba20f7..4bc83db70 100644 --- a/lib/redmine/quote_reply.rb +++ b/lib/redmine/quote_reply.rb @@ -20,12 +20,12 @@ module Redmine module QuoteReply module Helper - def javascript_for_quote_reply_include_tag - javascript_include_tag 'quote_reply' + 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}')" + 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 diff --git a/test/system/issues_reply_test.rb b/test/system/issues_reply_test.rb index 91ac0937d..7ab5e21c2 100644 --- a/test/system/issues_reply_test.rb +++ b/test/system/issues_reply_test.rb @@ -27,93 +27,236 @@ class IssuesReplyTest < ApplicationSystemTestCase :watchers, :journals, :journal_details, :versions, :workflows - setup do - log_user('jsmith', 'jsmith') - visit '/issues/1' - end - def test_reply_to_issue - within '.issue.details' do - click_link 'Quote' - end + 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')) + # 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 + window.getSelection().addRange(range); + JS - assert_field 'issue_notes', with: <<~TEXT - John Smith wrote: - > Unable to print recipes + assert_field 'issue_notes', with: <<~TEXT + John Smith wrote: + > Unable to print recipes - TEXT - assert_selector :css, '#issue_notes:focus' + TEXT + assert_selector :css, '#issue_notes:focus' + end end def test_reply_to_note - within '#change-1' do - click_link 'Quote' - end + 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 + assert_field 'issue_notes', with: <<~TEXT + Redmine Admin wrote in #note-1: + > Journal notes - TEXT - assert_selector :css, '#issue_notes:focus' + TEXT + assert_selector :css, '#issue_notes:focus' + end end def test_reply_to_issue_with_partial_quote - assert_text 'Unable to print recipes' + 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); + # 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 + window.getSelection().addRange(range); + JS - within '.issue.details' do - click_link 'Quote' - end + within '.issue.details' do + click_link 'Quote' + end - assert_field 'issue_notes', with: <<~TEXT - John Smith wrote: - > print + assert_field 'issue_notes', with: <<~TEXT + John Smith wrote: + > print - TEXT - assert_selector :css, '#issue_notes:focus' + TEXT + assert_selector :css, '#issue_notes:focus' + end end def test_reply_to_note_with_partial_quote - assert_text 'Journal notes' + 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); - # 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 - window.getSelection().addRange(range); - JS + within '#change-1' do + click_link 'Quote' + end - within '#change-1' do - click_link 'Quote' + 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 - assert_field 'issue_notes', with: <<~TEXT - Redmine Admin wrote in #note-1: - > Journal + 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. - TEXT - assert_selector :css, '#issue_notes:focus' + ## 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 index 5ca53adf7..ff5e48cd4 100644 --- a/test/system/messages_test.rb +++ b/test/system/messages_test.rb @@ -25,81 +25,95 @@ class MessagesTest < ApplicationSystemTestCase :custom_fields, :custom_values, :custom_fields_trackers, :watchers, :boards, :messages - setup do - log_user('jsmith', 'jsmith') - visit '/boards/1/topics/1' - end - def test_reply_to_topic_message - within '#content > .contextual' do - click_link 'Quote' - end + 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 + assert_field 'message_content', with: <<~TEXT + Redmine Admin wrote: + > This is the very first post + > in the forum - TEXT + TEXT + end end def test_reply_to_message - within '#message-2' do - click_link 'Quote' - end + 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 + assert_field 'message_content', with: <<~TEXT + Redmine Admin wrote in message#2: + > Reply to the first post - TEXT + TEXT + end end def test_reply_to_topic_message_with_partial_quote - assert_text /This is the very first post/ + 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')); + # 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 + window.getSelection().addRange(range); + JS - within '#content > .contextual' do - click_link 'Quote' - end + within '#content > .contextual' do + click_link 'Quote' + end - assert_field 'message_content', with: <<~TEXT - Redmine Admin wrote: - > in the forum + assert_field 'message_content', with: <<~TEXT + Redmine Admin wrote: + > in the forum - TEXT + TEXT + end end def test_reply_to_message_with_partial_quote - assert_text 'Reply to the first post' + 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 - # 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')); + within '#message-2' do + click_link 'Quote' + end - window.getSelection().addRange(range); - JS + assert_field 'message_content', with: <<~TEXT + Redmine Admin wrote in message#2: + > Reply to the first post - within '#message-2' do - click_link 'Quote' + TEXT end + end + + private - assert_field 'message_content', with: <<~TEXT - Redmine Admin wrote in message#2: - > Reply to the first post + def with_text_formatting(format) + with_settings text_formatting: format do + log_user('jsmith', 'jsmith') + visit '/boards/1/topics/1' - TEXT + 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 index 6c643c045..f3c9c110b 100644 --- a/test/unit/lib/redmine/quote_reply_helper_test.rb +++ b/test/unit/lib/redmine/quote_reply_helper_test.rb @@ -29,7 +29,7 @@ class QuoteReplyHelperTest < ActionView::TestCase 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'); return false;"}"| + 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=' -- 2.44.0