Project

General

Profile

Feature #41294 » Partial-quoting-feature-for-Issues-and-Forums.patch

A patch file that combines all patch files (0001...0008-*.patch) - Katsuya HIDAKA, 2024-09-23 07:29

View differences:

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
        > print
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
(15-15/15)