Feature #41294 » Partial-quoting-feature-for-Issues-and-Forums.patch
app/assets/javascripts/application.js | ||
---|---|---|
1262 | 1262 |
tribute.attach(element); |
1263 | 1263 |
} |
1264 | 1264 | |
1265 | ||
1266 | 1265 |
$(document).ready(setupAjaxIndicator); |
1267 | 1266 |
$(document).ready(hideOnLoad); |
1268 | 1267 |
$(document).ready(addFormObserversForDoubleSubmit); |
app/assets/javascripts/quote_reply.js | ||
---|---|---|
1 |
function quoteReply(path, selectorForContentElement, textFormatting) { |
|
2 |
const contentElement = $(selectorForContentElement).get(0); |
|
3 |
const selectedRange = QuoteExtractor.extract(contentElement); |
|
4 | ||
5 |
let formatter; |
|
6 | ||
7 |
if (textFormatting === 'common_mark') { |
|
8 |
formatter = new QuoteCommonMarkFormatter(); |
|
9 |
} else { |
|
10 |
formatter = new QuoteTextFormatter(); |
|
11 |
} |
|
12 | ||
13 |
$.ajax({ |
|
14 |
url: path, |
|
15 |
type: 'post', |
|
16 |
data: { quote: formatter.format(selectedRange) } |
|
17 |
}); |
|
18 |
} |
|
19 | ||
20 |
class QuoteExtractor { |
|
21 |
static extract(targetElement) { |
|
22 |
return new QuoteExtractor(targetElement).extract(); |
|
23 |
} |
|
24 | ||
25 |
constructor(targetElement) { |
|
26 |
this.targetElement = targetElement; |
|
27 |
this.selection = window.getSelection(); |
|
28 |
} |
|
29 | ||
30 |
extract() { |
|
31 |
const range = this.retriveSelectedRange(); |
|
32 | ||
33 |
if (!range) { |
|
34 |
return null; |
|
35 |
} |
|
36 | ||
37 |
if (!this.targetElement.contains(range.startContainer)) { |
|
38 |
range.setStartBefore(this.targetElement); |
|
39 |
} |
|
40 |
if (!this.targetElement.contains(range.endContainer)) { |
|
41 |
range.setEndAfter(this.targetElement); |
|
42 |
} |
|
43 | ||
44 |
return range; |
|
45 |
} |
|
46 | ||
47 |
retriveSelectedRange() { |
|
48 |
if (!this.isSelected) { |
|
49 |
return null; |
|
50 |
} |
|
51 | ||
52 |
// Retrive the first range that intersects with the target element. |
|
53 |
// NOTE: Firefox allows to select multiple ranges in the document. |
|
54 |
for (let i = 0; i < this.selection.rangeCount; i++) { |
|
55 |
let range = this.selection.getRangeAt(i); |
|
56 |
if (range.intersectsNode(this.targetElement)) { |
|
57 |
return range; |
|
58 |
} |
|
59 |
} |
|
60 |
return null; |
|
61 |
} |
|
62 | ||
63 |
get isSelected() { |
|
64 |
return this.selection.containsNode(this.targetElement, true); |
|
65 |
} |
|
66 |
} |
|
67 | ||
68 |
class QuoteTextFormatter { |
|
69 |
format(selectedRange) { |
|
70 |
if (!selectedRange) { |
|
71 |
return null; |
|
72 |
} |
|
73 | ||
74 |
const fragment = document.createElement('div'); |
|
75 |
fragment.appendChild(selectedRange.cloneContents()); |
|
76 | ||
77 |
// Remove all unnecessary anchor elements |
|
78 |
fragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove()); |
|
79 | ||
80 |
const html = this.adjustLineBreaks(fragment.innerHTML); |
|
81 | ||
82 |
const result = document.createElement('div'); |
|
83 |
result.innerHTML = html; |
|
84 | ||
85 |
// Replace continuous line breaks with a single line break and remove tab characters |
|
86 |
return result.textContent |
|
87 |
.trim() |
|
88 |
.replace(/\t/g, '') |
|
89 |
.replace(/\n+/g, "\n"); |
|
90 |
} |
|
91 | ||
92 |
adjustLineBreaks(html) { |
|
93 |
return html |
|
94 |
.replace(/<\/(h1|h2|h3|h4|div|p|li|tr)>/g, "\n</$1>") |
|
95 |
.replace(/<br>/g, "\n") |
|
96 |
} |
|
97 |
} |
|
98 | ||
99 |
class QuoteCommonMarkFormatter { |
|
100 |
format(selectedRange) { |
|
101 |
if (!selectedRange) { |
|
102 |
return null; |
|
103 |
} |
|
104 | ||
105 |
const htmlFragment = this.extractHtmlFragmentFrom(selectedRange); |
|
106 |
const preparedHtml = this.prepareHtml(htmlFragment); |
|
107 | ||
108 |
return this.convertHtmlToCommonMark(preparedHtml); |
|
109 |
} |
|
110 | ||
111 |
extractHtmlFragmentFrom(range) { |
|
112 |
const fragment = document.createElement('div'); |
|
113 |
const ancestorNodeName = range.commonAncestorContainer.nodeName; |
|
114 | ||
115 |
if (ancestorNodeName == 'CODE' || ancestorNodeName == '#text') { |
|
116 |
fragment.appendChild(this.wrapPreCode(range)); |
|
117 |
} else { |
|
118 |
fragment.appendChild(range.cloneContents()); |
|
119 |
} |
|
120 | ||
121 |
return fragment; |
|
122 |
} |
|
123 | ||
124 |
// When only the content within the `<code>` element is selected, |
|
125 |
// the HTML within the selection range does not include the `<pre><code>` element itself. |
|
126 |
// To create a complete code block, wrap the selected content with the `<pre><code>` tags. |
|
127 |
// |
|
128 |
// selected contentes => <pre><code class="ruby">selected contents</code></pre> |
|
129 |
wrapPreCode(range) { |
|
130 |
const rangeAncestor = range.commonAncestorContainer; |
|
131 | ||
132 |
let codeElement = null; |
|
133 | ||
134 |
if (rangeAncestor.nodeName == 'CODE') { |
|
135 |
codeElement = rangeAncestor; |
|
136 |
} else { |
|
137 |
codeElement = rangeAncestor.parentElement.closest('code'); |
|
138 |
} |
|
139 | ||
140 |
if (!codeElement) { |
|
141 |
return range.cloneContents(); |
|
142 |
} |
|
143 | ||
144 |
const pre = document.createElement('pre'); |
|
145 |
const code = codeElement.cloneNode(false); |
|
146 | ||
147 |
code.appendChild(range.cloneContents()); |
|
148 |
pre.appendChild(code); |
|
149 | ||
150 |
return pre; |
|
151 |
} |
|
152 | ||
153 |
convertHtmlToCommonMark(html) { |
|
154 |
const turndownService = new TurndownService({ |
|
155 |
codeBlockStyle: 'fenced', |
|
156 |
headingStyle: 'atx' |
|
157 |
}); |
|
158 | ||
159 |
turndownService.addRule('del', { |
|
160 |
filter: ['del'], |
|
161 |
replacement: content => `~~${content}~~` |
|
162 |
}); |
|
163 | ||
164 |
turndownService.addRule('checkList', { |
|
165 |
filter: node => { |
|
166 |
return node.type === 'checkbox' && node.parentNode.nodeName === 'LI'; |
|
167 |
}, |
|
168 |
replacement: (content, node) => { |
|
169 |
return node.checked ? '[x]' : '[ ]'; |
|
170 |
} |
|
171 |
}); |
|
172 | ||
173 |
// Table does not maintain its original format, |
|
174 |
// and the text within the table is displayed as it is |
|
175 |
// |
|
176 |
// | A | B | C | |
|
177 |
// |---|---|---| |
|
178 |
// | 1 | 2 | 3 | |
|
179 |
// => |
|
180 |
// A B C |
|
181 |
// 1 2 3 |
|
182 |
turndownService.addRule('table', { |
|
183 |
filter: ['td', 'th'], |
|
184 |
replacement: (content, node) => { |
|
185 |
const separator = node.parentElement.lastElementChild === node ? '' : ' '; |
|
186 |
return content + separator; |
|
187 |
} |
|
188 |
}); |
|
189 |
turndownService.addRule('tableHeading', { |
|
190 |
filter: ['thead', 'tbody', 'tfoot', 'tr'], |
|
191 |
replacement: (content, _node) => content |
|
192 |
}); |
|
193 |
turndownService.addRule('tableRow', { |
|
194 |
filter: ['tr'], |
|
195 |
replacement: (content, _node) => { |
|
196 |
return content + '\n' |
|
197 |
} |
|
198 |
}); |
|
199 | ||
200 |
return turndownService.turndown(html); |
|
201 |
} |
|
202 | ||
203 |
prepareHtml(htmlFragment) { |
|
204 |
// Remove all anchor elements. |
|
205 |
// <h1>Title1<a href="#Title" class="wiki-anchor">¶</a></h1> => <h1>Title1</h1> |
|
206 |
htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove()); |
|
207 | ||
208 |
// Convert code highlight blocks to CommonMark format code blocks. |
|
209 |
// <code class="ruby" data-language="ruby"> => <code class="language-ruby" data-language="ruby"> |
|
210 |
htmlFragment.querySelectorAll('code[data-language]').forEach(e => { |
|
211 |
e.classList.replace(e.dataset['language'], 'language-' + e.dataset['language']) |
|
212 |
}); |
|
213 | ||
214 |
return htmlFragment.innerHTML; |
|
215 |
} |
|
216 |
} |
app/assets/javascripts/turndown-7.2.0.min.js | ||
---|---|---|
1 |
/* |
|
2 |
* Turndown v7.2.0 |
|
3 |
* https://github.com/mixmark-io/turndown |
|
4 |
* Copyright (c) 2017 Dom Christie |
|
5 |
* Released under the MIT license |
|
6 |
* https://github.com/mixmark-io/turndown/blob/master/LICENSE |
|
7 |
*/ |
|
8 |
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})(); |
app/controllers/journals_controller.rb | ||
---|---|---|
31 | 31 |
helper :queries |
32 | 32 |
helper :attachments |
33 | 33 |
include QueriesHelper |
34 |
include Redmine::QuoteReply::Builder |
|
34 | 35 | |
35 | 36 |
def index |
36 | 37 |
retrieve_query |
... | ... | |
65 | 66 | |
66 | 67 |
def new |
67 | 68 |
@journal = Journal.visible.find(params[:journal_id]) if params[:journal_id] |
68 |
if @journal |
|
69 |
user = @journal.user |
|
70 |
text = @journal.notes |
|
71 |
@content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => user, :link => "#note-#{params[:journal_indice]}"})}\n> " |
|
72 |
else |
|
73 |
user = @issue.author |
|
74 |
text = @issue.description |
|
75 |
@content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> " |
|
76 |
end |
|
77 |
# Replaces pre blocks with [...] |
|
78 |
text = text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]') |
|
79 |
@content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n" |
|
69 |
@content = if @journal |
|
70 |
quote_issue_journal(@journal, indice: params[:journal_indice], partial_quote: params[:quote]) |
|
71 |
else |
|
72 |
quote_issue(@issue, partial_quote: params[:quote]) |
|
73 |
end |
|
80 | 74 |
rescue ActiveRecord::RecordNotFound |
81 | 75 |
render_404 |
82 | 76 |
end |
app/controllers/messages_controller.rb | ||
---|---|---|
29 | 29 |
helper :watchers |
30 | 30 |
helper :attachments |
31 | 31 |
include AttachmentsHelper |
32 |
include Redmine::QuoteReply::Builder |
|
32 | 33 | |
33 | 34 |
REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE) |
34 | 35 | |
... | ... | |
119 | 120 |
@subject = @message.subject |
120 | 121 |
@subject = "RE: #{@subject}" unless @subject.starts_with?('RE:') |
121 | 122 | |
122 |
if @message.root == @message |
|
123 |
@content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> " |
|
124 |
else |
|
125 |
@content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => @message.author, :link => "message##{@message.id}"})}\n> " |
|
126 |
end |
|
127 |
@content << @message.content.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n" |
|
123 |
@content = if @message.root == @message |
|
124 |
quote_root_message(@message, partial_quote: params[:quote]) |
|
125 |
else |
|
126 |
quote_message(@message, partial_quote: params[:quote]) |
|
127 |
end |
|
128 | 128 | |
129 | 129 |
respond_to do |format| |
130 | 130 |
format.html { render_404 } |
app/helpers/journals_helper.rb | ||
---|---|---|
18 | 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
19 | 19 | |
20 | 20 |
module JournalsHelper |
21 |
include Redmine::QuoteReply::Helper |
|
22 | ||
21 | 23 |
# Returns the attachments of a journal that are displayed as thumbnails |
22 | 24 |
def journal_thumbnail_attachments(journal) |
23 | 25 |
journal.attachments.select(&:thumbnailable?) |
... | ... | |
40 | 42 | |
41 | 43 |
if journal.notes.present? |
42 | 44 |
if options[:reply_links] |
43 |
links << link_to(icon_with_label('comment', l(:button_quote)), |
|
44 |
quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice), |
|
45 |
:remote => true, |
|
46 |
:method => 'post', |
|
47 |
:title => l(:button_quote), |
|
48 |
:class => 'icon-only icon-comment' |
|
49 |
) |
|
45 |
url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice) |
|
46 |
links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true) |
|
50 | 47 |
end |
51 | 48 |
if journal.editable_by?(User.current) |
52 | 49 |
links << link_to(icon_with_label('edit', l(:button_edit)), |
app/helpers/messages_helper.rb | ||
---|---|---|
18 | 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
19 | 19 | |
20 | 20 |
module MessagesHelper |
21 |
include Redmine::QuoteReply::Helper |
|
21 | 22 |
end |
app/views/issues/show.html.erb | ||
---|---|---|
1 |
<% content_for :header_tags do %> |
|
2 |
<%= javascripts_for_quote_reply_include_tag %> |
|
3 |
<% end %> |
|
4 | ||
1 | 5 |
<%= render :partial => 'action_menu' %> |
2 | 6 | |
3 | 7 |
<h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %> |
... | ... | |
84 | 88 |
<hr /> |
85 | 89 |
<div class="description"> |
86 | 90 |
<div class="contextual"> |
87 |
<%= 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? %>
|
|
91 |
<%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>
|
|
88 | 92 |
</div> |
89 | 93 | |
90 | 94 |
<p><strong><%=l(:field_description)%></strong></p> |
91 |
<div class="wiki"> |
|
95 |
<div id="issue_description_wiki" class="wiki">
|
|
92 | 96 |
<%= textilizable @issue, :description, :attachments => @issue.attachments %> |
93 | 97 |
</div> |
94 | 98 |
</div> |
app/views/messages/show.html.erb | ||
---|---|---|
1 |
<% content_for :header_tags do %> |
|
2 |
<%= javascripts_for_quote_reply_include_tag %> |
|
3 |
<% end %> |
|
4 | ||
1 | 5 |
<%= board_breadcrumb(@message) %> |
2 | 6 | |
3 | 7 |
<div class="contextual"> |
4 | 8 |
<%= watcher_link(@topic, User.current) %> |
5 |
<%= link_to( |
|
6 |
icon_with_label('comment', l(:button_quote)), |
|
7 |
{:action => 'quote', :id => @topic}, |
|
8 |
:method => 'get', |
|
9 |
:class => 'icon icon-comment', |
|
10 |
:remote => true) if !@topic.locked? && authorize_for('messages', 'reply') %> |
|
9 |
<%= quote_reply( |
|
10 |
url_for(:action => 'quote', :id => @topic, :format => 'js'), |
|
11 |
'#message_topic_wiki' |
|
12 |
) if !@topic.locked? && authorize_for('messages', 'reply') %> |
|
11 | 13 |
<%= link_to( |
12 | 14 |
icon_with_label('edit', l(:button_edit)), |
13 | 15 |
{:action => 'edit', :id => @topic}, |
... | ... | |
26 | 28 | |
27 | 29 |
<div class="message"> |
28 | 30 |
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p> |
29 |
<div class="wiki"> |
|
31 |
<div id="message_topic_wiki" class="wiki">
|
|
30 | 32 |
<%= textilizable(@topic, :content) %> |
31 | 33 |
</div> |
32 | 34 |
<%= link_to_attachments @topic, :author => false, :thumbnails => true %> |
... | ... | |
42 | 44 |
<% @replies.each do |message| %> |
43 | 45 |
<div class="message reply" id="<%= "message-#{message.id}" %>"> |
44 | 46 |
<div class="contextual"> |
45 |
<%= link_to( |
|
46 |
icon_with_label('comment', l(:button_quote), icon_only: true), |
|
47 |
{:action => 'quote', :id => message}, |
|
48 |
:remote => true, |
|
49 |
:method => 'get', |
|
50 |
:title => l(:button_quote), |
|
51 |
:class => 'icon icon-comment' |
|
47 |
<%= quote_reply( |
|
48 |
url_for(:action => 'quote', :id => message, :format => 'js'), |
|
49 |
"#message-#{message.id} .wiki", |
|
50 |
icon_only: true |
|
52 | 51 |
) if !@topic.locked? && authorize_for('messages', 'reply') %> |
53 | 52 |
<%= link_to( |
54 | 53 |
icon_with_label('edit', l(:button_edit), icon_only: true), |
lib/redmine/quote_reply.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
# Redmine - project management software |
|
4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
5 |
# |
|
6 |
# This program is free software; you can redistribute it and/or |
|
7 |
# modify it under the terms of the GNU General Public License |
|
8 |
# as published by the Free Software Foundation; either version 2 |
|
9 |
# of the License, or (at your option) any later version. |
|
10 |
# |
|
11 |
# This program is distributed in the hope that it will be useful, |
|
12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
14 |
# GNU General Public License for more details. |
|
15 |
# |
|
16 |
# You should have received a copy of the GNU General Public License |
|
17 |
# along with this program; if not, write to the Free Software |
|
18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
19 | ||
20 |
module Redmine |
|
21 |
module QuoteReply |
|
22 |
module Helper |
|
23 |
def javascripts_for_quote_reply_include_tag |
|
24 |
javascript_include_tag 'turndown-7.2.0.min', 'quote_reply' |
|
25 |
end |
|
26 | ||
27 |
def quote_reply(url, selector_for_content, icon_only: false) |
|
28 |
quote_reply_function = "quoteReply('#{j url}', '#{j selector_for_content}', '#{j Setting.text_formatting}')" |
|
29 | ||
30 |
html_options = { class: 'icon icon-comment' } |
|
31 |
html_options[:title] = l(:button_quote) if icon_only |
|
32 | ||
33 |
link_to_function( |
|
34 |
icon_with_label('comment', l(:button_quote), icon_only: icon_only), |
|
35 |
quote_reply_function, |
|
36 |
html_options |
|
37 |
) |
|
38 |
end |
|
39 |
end |
|
40 | ||
41 |
module Builder |
|
42 |
def quote_issue(issue, partial_quote: nil) |
|
43 |
user = issue.author |
|
44 | ||
45 |
build_quote( |
|
46 |
"#{ll(Setting.default_language, :text_user_wrote, user)}\n> ", |
|
47 |
issue.description, |
|
48 |
partial_quote |
|
49 |
) |
|
50 |
end |
|
51 | ||
52 |
def quote_issue_journal(journal, indice:, partial_quote: nil) |
|
53 |
user = journal.user |
|
54 | ||
55 |
build_quote( |
|
56 |
"#{ll(Setting.default_language, :text_user_wrote_in, {value: journal.user, link: "#note-#{indice}"})}\n> ", |
|
57 |
journal.notes, |
|
58 |
partial_quote |
|
59 |
) |
|
60 |
end |
|
61 | ||
62 |
def quote_root_message(message, partial_quote: nil) |
|
63 |
build_quote( |
|
64 |
"#{ll(Setting.default_language, :text_user_wrote, message.author)}\n> ", |
|
65 |
message.content, |
|
66 |
partial_quote |
|
67 |
) |
|
68 |
end |
|
69 | ||
70 |
def quote_message(message, partial_quote: nil) |
|
71 |
build_quote( |
|
72 |
"#{ll(Setting.default_language, :text_user_wrote_in, {value: message.author, link: "message##{message.id}"})}\n> ", |
|
73 |
message.content, |
|
74 |
partial_quote |
|
75 |
) |
|
76 |
end |
|
77 | ||
78 |
private |
|
79 | ||
80 |
def build_quote(quote_header, text, partial_quote = nil) |
|
81 |
quote_text = partial_quote.presence || text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]') |
|
82 | ||
83 |
"#{quote_header}#{quote_text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"}" |
|
84 |
end |
|
85 |
end |
|
86 |
end |
|
87 |
end |
test/functional/journals_controller_test.rb | ||
---|---|---|
168 | 168 | |
169 | 169 |
def test_reply_to_issue |
170 | 170 |
@request.session[:user_id] = 2 |
171 |
get(:new, :params => {:id => 6}, :xhr => true)
|
|
171 |
post(:new, :params => {:id => 6}, :xhr => true)
|
|
172 | 172 |
assert_response :success |
173 | 173 | |
174 | 174 |
assert_equal 'text/javascript', response.media_type |
... | ... | |
177 | 177 | |
178 | 178 |
def test_reply_to_issue_without_permission |
179 | 179 |
@request.session[:user_id] = 7 |
180 |
get(:new, :params => {:id => 6}, :xhr => true)
|
|
180 |
post(:new, :params => {:id => 6}, :xhr => true)
|
|
181 | 181 |
assert_response :forbidden |
182 | 182 |
end |
183 | 183 | |
184 | 184 |
def test_reply_to_note |
185 | 185 |
@request.session[:user_id] = 2 |
186 |
get(
|
|
186 |
post(
|
|
187 | 187 |
:new, |
188 | 188 |
:params => { |
189 | 189 |
:id => 6, |
... | ... | |
202 | 202 |
journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true) |
203 | 203 |
@request.session[:user_id] = 2 |
204 | 204 | |
205 |
get(
|
|
205 |
post(
|
|
206 | 206 |
:new, |
207 | 207 |
:params => { |
208 | 208 |
:id => 2, |
... | ... | |
215 | 215 |
assert_include '> Privates notes', response.body |
216 | 216 | |
217 | 217 |
Role.find(1).remove_permission! :view_private_notes |
218 |
get(
|
|
218 |
post(
|
|
219 | 219 |
:new, |
220 | 220 |
:params => { |
221 | 221 |
:id => 2, |
... | ... | |
226 | 226 |
assert_response :not_found |
227 | 227 |
end |
228 | 228 | |
229 |
def test_reply_to_issue_with_partial_quote |
|
230 |
@request.session[:user_id] = 2 |
|
231 | ||
232 |
params = { id: 6, quote: 'a private subproject of cookbook' } |
|
233 |
post :new, params: params, xhr: true |
|
234 | ||
235 |
assert_response :success |
|
236 |
assert_equal 'text/javascript', response.media_type |
|
237 |
assert_include 'John Smith wrote:', response.body |
|
238 |
assert_include '> a private subproject of cookbook', response.body |
|
239 |
end |
|
240 | ||
241 |
def test_reply_to_note_with_partial_quote |
|
242 |
@request.session[:user_id] = 2 |
|
243 | ||
244 |
params = { id: 6, journal_id: 4, journal_indice: 1, quote: 'a private version' } |
|
245 |
post :new, params: params, xhr: true |
|
246 | ||
247 |
assert_response :success |
|
248 |
assert_equal 'text/javascript', response.media_type |
|
249 |
assert_include 'Redmine Admin wrote in #note-1:', response.body |
|
250 |
assert_include '> a private version', response.body |
|
251 |
end |
|
252 | ||
229 | 253 |
def test_edit_xhr |
230 | 254 |
@request.session[:user_id] = 1 |
231 | 255 |
get(:edit, :params => {:id => 2}, :xhr => true) |
test/functional/messages_controller_test.rb | ||
---|---|---|
288 | 288 | |
289 | 289 |
def test_quote_if_message_is_root |
290 | 290 |
@request.session[:user_id] = 2 |
291 |
get(
|
|
291 |
post(
|
|
292 | 292 |
:quote, |
293 | 293 |
:params => { |
294 | 294 |
:board_id => 1, |
... | ... | |
306 | 306 | |
307 | 307 |
def test_quote_if_message_is_not_root |
308 | 308 |
@request.session[:user_id] = 2 |
309 |
get(
|
|
309 |
post(
|
|
310 | 310 |
:quote, |
311 | 311 |
:params => { |
312 | 312 |
:board_id => 1, |
... | ... | |
322 | 322 |
assert_include '> An other reply', response.body |
323 | 323 |
end |
324 | 324 | |
325 |
def test_quote_with_partial_quote_if_message_is_root |
|
326 |
@request.session[:user_id] = 2 |
|
327 | ||
328 |
params = { board_id: 1, id: 1, |
|
329 |
quote: "the very first post\nin the forum" } |
|
330 |
post :quote, params: params, xhr: true |
|
331 | ||
332 |
assert_response :success |
|
333 |
assert_equal 'text/javascript', response.media_type |
|
334 | ||
335 |
assert_include 'RE: First post', response.body |
|
336 |
assert_include "Redmine Admin wrote:", response.body |
|
337 |
assert_include '> the very first post\n> in the forum', response.body |
|
338 |
end |
|
339 | ||
340 |
def test_quote_with_partial_quote_if_message_is_not_root |
|
341 |
@request.session[:user_id] = 2 |
|
342 | ||
343 |
params = { board_id: 1, id: 3, quote: 'other reply' } |
|
344 |
post :quote, params: params, xhr: true |
|
345 | ||
346 |
assert_response :success |
|
347 |
assert_equal 'text/javascript', response.media_type |
|
348 | ||
349 |
assert_include 'RE: First post', response.body |
|
350 |
assert_include 'John Smith wrote in message#3:', response.body |
|
351 |
assert_include '> other reply', response.body |
|
352 |
end |
|
353 | ||
325 | 354 |
def test_quote_as_html_should_respond_with_404 |
326 | 355 |
@request.session[:user_id] = 2 |
327 |
get(
|
|
356 |
post(
|
|
328 | 357 |
:quote, |
329 | 358 |
:params => { |
330 | 359 |
:board_id => 1, |
test/helpers/journals_helper_test.rb | ||
---|---|---|
57 | 57 |
journals = issue.visible_journals_with_index # add indice |
58 | 58 |
journal_actions = render_journal_actions(issue, journals.first, {reply_links: true}) |
59 | 59 | |
60 |
assert_select_in journal_actions, 'a[title=?][class="icon-only icon-comment"]', 'Quote'
|
|
60 |
assert_select_in journal_actions, 'a[title=?][class="icon icon-comment"]', 'Quote' |
|
61 | 61 |
assert_select_in journal_actions, 'a[title=?][class="icon-only icon-edit"]', 'Edit' |
62 | 62 |
assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-del"]' |
63 | 63 |
assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-copy-link"]' |
test/system/issues_reply_test.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
# Redmine - project management software |
|
4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
5 |
# |
|
6 |
# This program is free software; you can redistribute it and/or |
|
7 |
# modify it under the terms of the GNU General Public License |
|
8 |
# as published by the Free Software Foundation; either version 2 |
|
9 |
# of the License, or (at your option) any later version. |
|
10 |
# |
|
11 |
# This program is distributed in the hope that it will be useful, |
|
12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
14 |
# GNU General Public License for more details. |
|
15 |
# |
|
16 |
# You should have received a copy of the GNU General Public License |
|
17 |
# along with this program; if not, write to the Free Software |
|
18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
19 | ||
20 |
require_relative '../application_system_test_case' |
|
21 | ||
22 |
class IssuesReplyTest < ApplicationSystemTestCase |
|
23 |
fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles, |
|
24 |
:trackers, :projects_trackers, :enabled_modules, |
|
25 |
:issue_statuses, :issues, :issue_categories, |
|
26 |
:enumerations, :custom_fields, :custom_values, :custom_fields_trackers, |
|
27 |
:watchers, :journals, :journal_details, :versions, |
|
28 |
:workflows |
|
29 | ||
30 |
def test_reply_to_issue |
|
31 |
with_text_formatting 'common_mark' do |
|
32 |
within '.issue.details' do |
|
33 |
click_link 'Quote' |
|
34 |
end |
|
35 | ||
36 |
# Select the other than the issue description element. |
|
37 |
page.execute_script <<-JS |
|
38 |
const range = document.createRange(); |
|
39 |
// Select "Description" text. |
|
40 |
range.selectNodeContents(document.querySelector('.description > p')) |
|
41 | ||
42 |
window.getSelection().addRange(range); |
|
43 |
JS |
|
44 | ||
45 |
assert_field 'issue_notes', with: <<~TEXT |
|
46 |
John Smith wrote: |
|
47 |
> Unable to print recipes |
|
48 | ||
49 |
TEXT |
|
50 |
assert_selector :css, '#issue_notes:focus' |
|
51 |
end |
|
52 |
end |
|
53 | ||
54 |
def test_reply_to_note |
|
55 |
with_text_formatting 'textile' do |
|
56 |
within '#change-1' do |
|
57 |
click_link 'Quote' |
|
58 |
end |
|
59 | ||
60 |
assert_field 'issue_notes', with: <<~TEXT |
|
61 |
Redmine Admin wrote in #note-1: |
|
62 |
> Journal notes |
|
63 | ||
64 |
TEXT |
|
65 |
assert_selector :css, '#issue_notes:focus' |
|
66 |
end |
|
67 |
end |
|
68 | ||
69 |
def test_reply_to_issue_with_partial_quote |
|
70 |
with_text_formatting 'common_mark' do |
|
71 |
assert_text 'Unable to print recipes' |
|
72 | ||
73 |
# Select only the "print" text from the text "Unable to print recipes" in the description. |
|
74 |
page.execute_script <<-JS |
|
75 |
const range = document.createRange(); |
|
76 |
const wiki = document.querySelector('#issue_description_wiki > p').childNodes[0]; |
|
77 |
range.setStart(wiki, 10); |
|
78 |
range.setEnd(wiki, 15); |
|
79 | ||
80 |
window.getSelection().addRange(range); |
|
81 |
JS |
|
82 | ||
83 |
within '.issue.details' do |
|
84 |
click_link 'Quote' |
|
85 |
end |
|
86 | ||
87 |
assert_field 'issue_notes', with: <<~TEXT |
|
88 |
John Smith wrote: |
|
89 |
|
|
90 | ||
91 |
TEXT |
|
92 |
assert_selector :css, '#issue_notes:focus' |
|
93 |
end |
|
94 |
end |
|
95 | ||
96 |
def test_reply_to_note_with_partial_quote |
|
97 |
with_text_formatting 'textile' do |
|
98 |
assert_text 'Journal notes' |
|
99 | ||
100 |
# Select the entire details of the note#1 and the part of the note#1's text. |
|
101 |
page.execute_script <<-JS |
|
102 |
const range = document.createRange(); |
|
103 |
range.setStartBefore(document.querySelector('#change-1 .details')); |
|
104 |
// Select only the text "Journal" from the text "Journal notes" in the note-1. |
|
105 |
range.setEnd(document.querySelector('#change-1 .wiki > p').childNodes[0], 7); |
|
106 | ||
107 |
window.getSelection().addRange(range); |
|
108 |
JS |
|
109 | ||
110 |
within '#change-1' do |
|
111 |
click_link 'Quote' |
|
112 |
end |
|
113 | ||
114 |
assert_field 'issue_notes', with: <<~TEXT |
|
115 |
Redmine Admin wrote in #note-1: |
|
116 |
> Journal |
|
117 | ||
118 |
TEXT |
|
119 |
assert_selector :css, '#issue_notes:focus' |
|
120 |
end |
|
121 |
end |
|
122 | ||
123 |
def test_partial_quotes_should_be_quoted_in_plain_text_when_text_format_is_textile |
|
124 |
issues(:issues_001).update!(description: <<~DESC) |
|
125 |
# "Redmine":https://redmine.org is |
|
126 |
# a *flexible* project management |
|
127 |
# web application. |
|
128 |
DESC |
|
129 | ||
130 |
with_text_formatting 'textile' do |
|
131 |
assert_text /a flexible project management/ |
|
132 | ||
133 |
# Select the entire description of the issue. |
|
134 |
page.execute_script <<-JS |
|
135 |
const range = document.createRange(); |
|
136 |
range.selectNodeContents(document.querySelector('#issue_description_wiki')) |
|
137 |
window.getSelection().addRange(range); |
|
138 |
JS |
|
139 | ||
140 |
within '.issue.details' do |
|
141 |
click_link 'Quote' |
|
142 |
end |
|
143 | ||
144 |
expected_value = [ |
|
145 |
'John Smith wrote:', |
|
146 |
'> Redmine is', |
|
147 |
'> a flexible project management', |
|
148 |
'> web application.', |
|
149 |
'', |
|
150 |
'' |
|
151 |
] |
|
152 |
assert_equal expected_value.join("\n"), find_field('issue_notes').value |
|
153 |
end |
|
154 |
end |
|
155 | ||
156 |
def test_partial_quotes_should_be_quoted_in_common_mark_format_when_text_format_is_common_mark |
|
157 |
issues(:issues_001).update!(description: <<~DESC) |
|
158 |
# Title1 |
|
159 |
[Redmine](https://redmine.org) is a **flexible** project management web application. |
|
160 | ||
161 |
## Title2 |
|
162 |
* List1 |
|
163 |
* List1-1 |
|
164 |
* List2 |
|
165 | ||
166 |
1. Number1 |
|
167 |
1. Number2 |
|
168 | ||
169 |
### Title3 |
|
170 |
```ruby |
|
171 |
puts "Hello, world!" |
|
172 |
``` |
|
173 |
``` |
|
174 |
$ bin/rails db:migrate |
|
175 |
``` |
|
176 | ||
177 |
| Subject1 | Subject2 | |
|
178 |
| -------- | -------- | |
|
179 |
| ~~cell1~~| **cell2**| |
|
180 | ||
181 |
* [ ] Checklist1 |
|
182 |
* [x] Checklist2 |
|
183 | ||
184 |
[[WikiPage]] |
|
185 |
Issue #14 |
|
186 |
Issue ##2 |
|
187 | ||
188 |
Redmine is `a flexible` project management |
|
189 | ||
190 |
web application. |
|
191 |
DESC |
|
192 | ||
193 |
with_text_formatting 'common_mark' do |
|
194 |
assert_text /Title1/ |
|
195 | ||
196 |
# Select the entire description of the issue. |
|
197 |
page.execute_script <<-JS |
|
198 |
const range = document.createRange(); |
|
199 |
range.selectNodeContents(document.querySelector('#issue_description_wiki')) |
|
200 |
window.getSelection().addRange(range); |
|
201 |
JS |
|
202 | ||
203 |
within '.issue.details' do |
|
204 |
click_link 'Quote' |
|
205 |
end |
|
206 | ||
207 |
expected_value = [ |
|
208 |
'John Smith wrote:', |
|
209 |
'> # Title1', |
|
210 |
'> ', |
|
211 |
'> [Redmine](https://redmine.org) is a **flexible** project management web application.', |
|
212 |
'> ', |
|
213 |
'> ## Title2', |
|
214 |
'> ', |
|
215 |
'> * List1', |
|
216 |
'> * List1-1', |
|
217 |
'> * List2', |
|
218 |
'> ', |
|
219 |
'> 1. Number1', |
|
220 |
'> 2. Number2', |
|
221 |
'> ', |
|
222 |
'> ### Title3', |
|
223 |
'> ', |
|
224 |
'> ```ruby', |
|
225 |
'> puts "Hello, world!"', |
|
226 |
'> ```', |
|
227 |
'> ', |
|
228 |
'> ```', |
|
229 |
'> $ bin/rails db:migrate', |
|
230 |
'> ```', |
|
231 |
'> ', |
|
232 |
'> Subject1 Subject2', |
|
233 |
'> ~~cell1~~ **cell2**', |
|
234 |
'> ', |
|
235 |
'> * [ ] Checklist1', |
|
236 |
'> * [x] Checklist2', |
|
237 |
'> ', |
|
238 |
'> [WikiPage](/projects/ecookbook/wiki/WikiPage) ', |
|
239 |
'> Issue [#14](/issues/14 "Bug: Private issue on public project (New)") ', |
|
240 |
'> Issue [Feature request #2: Add ingredients categories](/issues/2 "Status: Assigned")', |
|
241 |
'> ', |
|
242 |
'> Redmine is `a flexible` project management', |
|
243 |
'> ', |
|
244 |
'> web application.', |
|
245 |
'', |
|
246 |
'' |
|
247 |
] |
|
248 |
assert_equal expected_value.join("\n"), find_field('issue_notes').value |
|
249 |
end |
|
250 |
end |
|
251 | ||
252 |
private |
|
253 | ||
254 |
def with_text_formatting(format) |
|
255 |
with_settings text_formatting: format do |
|
256 |
log_user('jsmith', 'jsmith') |
|
257 |
visit '/issues/1' |
|
258 | ||
259 |
yield |
|
260 |
end |
|
261 |
end |
|
262 |
end |
test/system/messages_test.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
# Redmine - project management software |
|
4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
5 |
# |
|
6 |
# This program is free software; you can redistribute it and/or |
|
7 |
# modify it under the terms of the GNU General Public License |
|
8 |
# as published by the Free Software Foundation; either version 2 |
|
9 |
# of the License, or (at your option) any later version. |
|
10 |
# |
|
11 |
# This program is distributed in the hope that it will be useful, |
|
12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
14 |
# GNU General Public License for more details. |
|
15 |
# |
|
16 |
# You should have received a copy of the GNU General Public License |
|
17 |
# along with this program; if not, write to the Free Software |
|
18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
19 | ||
20 |
require_relative '../application_system_test_case' |
|
21 | ||
22 |
class MessagesTest < ApplicationSystemTestCase |
|
23 |
fixtures :projects, :users, :roles, :members, :member_roles, |
|
24 |
:enabled_modules, :enumerations, |
|
25 |
:custom_fields, :custom_values, :custom_fields_trackers, |
|
26 |
:watchers, :boards, :messages |
|
27 | ||
28 |
def test_reply_to_topic_message |
|
29 |
with_text_formatting 'common_mark' do |
|
30 |
within '#content > .contextual' do |
|
31 |
click_link 'Quote' |
|
32 |
end |
|
33 | ||
34 |
assert_field 'message_content', with: <<~TEXT |
|
35 |
Redmine Admin wrote: |
|
36 |
> This is the very first post |
|
37 |
> in the forum |
|
38 | ||
39 |
TEXT |
|
40 |
end |
|
41 |
end |
|
42 | ||
43 |
def test_reply_to_message |
|
44 |
with_text_formatting 'textile' do |
|
45 |
within '#message-2' do |
|
46 |
click_link 'Quote' |
|
47 |
end |
|
48 | ||
49 |
assert_field 'message_content', with: <<~TEXT |
|
50 |
Redmine Admin wrote in message#2: |
|
51 |
> Reply to the first post |
|
52 | ||
53 |
TEXT |
|
54 |
end |
|
55 |
end |
|
56 | ||
57 |
def test_reply_to_topic_message_with_partial_quote |
|
58 |
with_text_formatting 'textile' do |
|
59 |
assert_text /This is the very first post/ |
|
60 | ||
61 |
# Select the part of the topic message through the entire text of the attachment below it. |
|
62 |
page.execute_script <<-'JS' |
|
63 |
const range = document.createRange(); |
|
64 |
const message = document.querySelector('#message_topic_wiki'); |
|
65 |
// Select only the text "in the forum" from the text "This is the very first post\nin the forum". |
|
66 |
range.setStartBefore(message.querySelector('p').childNodes[2]); |
|
67 |
range.setEndAfter(message.parentNode.querySelector('.attachments')); |
|
68 | ||
69 |
window.getSelection().addRange(range); |
|
70 |
JS |
|
71 | ||
72 |
within '#content > .contextual' do |
|
73 |
click_link 'Quote' |
|
74 |
end |
|
75 | ||
76 |
assert_field 'message_content', with: <<~TEXT |
|
77 |
Redmine Admin wrote: |
|
78 |
> in the forum |
|
79 | ||
80 |
TEXT |
|
81 |
end |
|
82 |
end |
|
83 | ||
84 |
def test_reply_to_message_with_partial_quote |
|
85 |
with_text_formatting 'common_mark' do |
|
86 |
assert_text 'Reply to the first post' |
|
87 | ||
88 |
# Select the entire message, including the subject and headers of messages #2 and #3. |
|
89 |
page.execute_script <<-JS |
|
90 |
const range = document.createRange(); |
|
91 |
range.setStartBefore(document.querySelector('#message-2')); |
|
92 |
range.setEndAfter(document.querySelector('#message-3')); |
|
93 | ||
94 |
window.getSelection().addRange(range); |
|
95 |
JS |
|
96 | ||
97 |
within '#message-2' do |
|
98 |
click_link 'Quote' |
|
99 |
end |
|
100 | ||
101 |
assert_field 'message_content', with: <<~TEXT |
|
102 |
Redmine Admin wrote in message#2: |
|
103 |
> Reply to the first post |
|
104 | ||
105 |
TEXT |
|
106 |
end |
|
107 |
end |
|
108 | ||
109 |
private |
|
110 | ||
111 |
def with_text_formatting(format) |
|
112 |
with_settings text_formatting: format do |
|
113 |
log_user('jsmith', 'jsmith') |
|
114 |
visit '/boards/1/topics/1' |
|
115 | ||
116 |
yield |
|
117 |
end |
|
118 |
end |
|
119 |
end |
test/unit/lib/redmine/quote_reply_helper_test.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
# Redmine - project management software |
|
4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
5 |
# |
|
6 |
# This program is free software; you can redistribute it and/or |
|
7 |
# modify it under the terms of the GNU General Public License |
|
8 |
# as published by the Free Software Foundation; either version 2 |
|
9 |
# of the License, or (at your option) any later version. |
|
10 |
# |
|
11 |
# This program is distributed in the hope that it will be useful, |
|
12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
14 |
# GNU General Public License for more details. |
|
15 |
# |
|
16 |
# You should have received a copy of the GNU General Public License |
|
17 |
# along with this program; if not, write to the Free Software |
|
18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
19 | ||
20 |
require_relative '../../../test_helper' |
|
21 | ||
22 |
class QuoteReplyHelperTest < ActionView::TestCase |
|
23 |
include ERB::Util |
|
24 |
include Redmine::QuoteReply::Helper |
|
25 | ||
26 |
fixtures :issues |
|
27 | ||
28 |
def test_quote_reply |
|
29 |
url = quoted_issue_path(issues(:issues_001)) |
|
30 | ||
31 |
a_tag = quote_reply(url, '#issue_description_wiki') |
|
32 |
assert_includes a_tag, %|onclick="#{h "quoteReply('/issues/1/quoted', '#issue_description_wiki', 'common_mark'); return false;"}"| |
|
33 |
assert_includes a_tag, %|class="icon icon-comment"| |
|
34 |
assert_not_includes a_tag, 'title=' |
|
35 | ||
36 |
# When icon_only is true |
|
37 |
a_tag = quote_reply(url, '#issue_description_wiki', icon_only: true) |
|
38 |
assert_includes a_tag, %|title="Quote"| |
|
39 |
end |
|
40 |
end |