mirror of
https://github.com/redmine/redmine.git
synced 2025-11-02 11:25:55 +01:00
Partial quoting feature for Issues and Forums (#41294).
Patch by Katsuya HIDAKA (user:hidakatsuya). git-svn-id: https://svn.redmine.org/redmine/trunk@23107 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
@@ -1261,7 +1261,6 @@ function inlineAutoComplete(element) {
|
|||||||
tribute.attach(element);
|
tribute.attach(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$(document).ready(setupAjaxIndicator);
|
$(document).ready(setupAjaxIndicator);
|
||||||
$(document).ready(hideOnLoad);
|
$(document).ready(hideOnLoad);
|
||||||
$(document).ready(addFormObserversForDoubleSubmit);
|
$(document).ready(addFormObserversForDoubleSubmit);
|
||||||
|
|||||||
216
app/assets/javascripts/quote_reply.js
Normal file
216
app/assets/javascripts/quote_reply.js
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
function quoteReply(path, selectorForContentElement, textFormatting) {
|
||||||
|
const contentElement = $(selectorForContentElement).get(0);
|
||||||
|
const selectedRange = QuoteExtractor.extract(contentElement);
|
||||||
|
|
||||||
|
let formatter;
|
||||||
|
|
||||||
|
if (textFormatting === 'common_mark') {
|
||||||
|
formatter = new QuoteCommonMarkFormatter();
|
||||||
|
} else {
|
||||||
|
formatter = new QuoteTextFormatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: path,
|
||||||
|
type: 'post',
|
||||||
|
data: { quote: formatter.format(selectedRange) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuoteExtractor {
|
||||||
|
static extract(targetElement) {
|
||||||
|
return new QuoteExtractor(targetElement).extract();
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(targetElement) {
|
||||||
|
this.targetElement = targetElement;
|
||||||
|
this.selection = window.getSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
extract() {
|
||||||
|
const range = this.retriveSelectedRange();
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.targetElement.contains(range.startContainer)) {
|
||||||
|
range.setStartBefore(this.targetElement);
|
||||||
|
}
|
||||||
|
if (!this.targetElement.contains(range.endContainer)) {
|
||||||
|
range.setEndAfter(this.targetElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
retriveSelectedRange() {
|
||||||
|
if (!this.isSelected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrive the first range that intersects with the target element.
|
||||||
|
// NOTE: Firefox allows to select multiple ranges in the document.
|
||||||
|
for (let i = 0; i < this.selection.rangeCount; i++) {
|
||||||
|
let range = this.selection.getRangeAt(i);
|
||||||
|
if (range.intersectsNode(this.targetElement)) {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSelected() {
|
||||||
|
return this.selection.containsNode(this.targetElement, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuoteTextFormatter {
|
||||||
|
format(selectedRange) {
|
||||||
|
if (!selectedRange) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createElement('div');
|
||||||
|
fragment.appendChild(selectedRange.cloneContents());
|
||||||
|
|
||||||
|
// Remove all unnecessary anchor elements
|
||||||
|
fragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
|
||||||
|
|
||||||
|
const html = this.adjustLineBreaks(fragment.innerHTML);
|
||||||
|
|
||||||
|
const result = document.createElement('div');
|
||||||
|
result.innerHTML = html;
|
||||||
|
|
||||||
|
// Replace continuous line breaks with a single line break and remove tab characters
|
||||||
|
return result.textContent
|
||||||
|
.trim()
|
||||||
|
.replace(/\t/g, '')
|
||||||
|
.replace(/\n+/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustLineBreaks(html) {
|
||||||
|
return html
|
||||||
|
.replace(/<\/(h1|h2|h3|h4|div|p|li|tr)>/g, "\n</$1>")
|
||||||
|
.replace(/<br>/g, "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuoteCommonMarkFormatter {
|
||||||
|
format(selectedRange) {
|
||||||
|
if (!selectedRange) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlFragment = this.extractHtmlFragmentFrom(selectedRange);
|
||||||
|
const preparedHtml = this.prepareHtml(htmlFragment);
|
||||||
|
|
||||||
|
return this.convertHtmlToCommonMark(preparedHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractHtmlFragmentFrom(range) {
|
||||||
|
const fragment = document.createElement('div');
|
||||||
|
const ancestorNodeName = range.commonAncestorContainer.nodeName;
|
||||||
|
|
||||||
|
if (ancestorNodeName == 'CODE' || ancestorNodeName == '#text') {
|
||||||
|
fragment.appendChild(this.wrapPreCode(range));
|
||||||
|
} else {
|
||||||
|
fragment.appendChild(range.cloneContents());
|
||||||
|
}
|
||||||
|
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When only the content within the `<code>` element is selected,
|
||||||
|
// the HTML within the selection range does not include the `<pre><code>` element itself.
|
||||||
|
// To create a complete code block, wrap the selected content with the `<pre><code>` tags.
|
||||||
|
//
|
||||||
|
// selected contentes => <pre><code class="ruby">selected contents</code></pre>
|
||||||
|
wrapPreCode(range) {
|
||||||
|
const rangeAncestor = range.commonAncestorContainer;
|
||||||
|
|
||||||
|
let codeElement = null;
|
||||||
|
|
||||||
|
if (rangeAncestor.nodeName == 'CODE') {
|
||||||
|
codeElement = rangeAncestor;
|
||||||
|
} else {
|
||||||
|
codeElement = rangeAncestor.parentElement.closest('code');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!codeElement) {
|
||||||
|
return range.cloneContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
const code = codeElement.cloneNode(false);
|
||||||
|
|
||||||
|
code.appendChild(range.cloneContents());
|
||||||
|
pre.appendChild(code);
|
||||||
|
|
||||||
|
return pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
convertHtmlToCommonMark(html) {
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
codeBlockStyle: 'fenced',
|
||||||
|
headingStyle: 'atx'
|
||||||
|
});
|
||||||
|
|
||||||
|
turndownService.addRule('del', {
|
||||||
|
filter: ['del'],
|
||||||
|
replacement: content => `~~${content}~~`
|
||||||
|
});
|
||||||
|
|
||||||
|
turndownService.addRule('checkList', {
|
||||||
|
filter: node => {
|
||||||
|
return node.type === 'checkbox' && node.parentNode.nodeName === 'LI';
|
||||||
|
},
|
||||||
|
replacement: (content, node) => {
|
||||||
|
return node.checked ? '[x]' : '[ ]';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table does not maintain its original format,
|
||||||
|
// and the text within the table is displayed as it is
|
||||||
|
//
|
||||||
|
// | A | B | C |
|
||||||
|
// |---|---|---|
|
||||||
|
// | 1 | 2 | 3 |
|
||||||
|
// =>
|
||||||
|
// A B C
|
||||||
|
// 1 2 3
|
||||||
|
turndownService.addRule('table', {
|
||||||
|
filter: ['td', 'th'],
|
||||||
|
replacement: (content, node) => {
|
||||||
|
const separator = node.parentElement.lastElementChild === node ? '' : ' ';
|
||||||
|
return content + separator;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
turndownService.addRule('tableHeading', {
|
||||||
|
filter: ['thead', 'tbody', 'tfoot', 'tr'],
|
||||||
|
replacement: (content, _node) => content
|
||||||
|
});
|
||||||
|
turndownService.addRule('tableRow', {
|
||||||
|
filter: ['tr'],
|
||||||
|
replacement: (content, _node) => {
|
||||||
|
return content + '\n'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return turndownService.turndown(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareHtml(htmlFragment) {
|
||||||
|
// Remove all anchor elements.
|
||||||
|
// <h1>Title1<a href="#Title" class="wiki-anchor">¶</a></h1> => <h1>Title1</h1>
|
||||||
|
htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
|
||||||
|
|
||||||
|
// Convert code highlight blocks to CommonMark format code blocks.
|
||||||
|
// <code class="ruby" data-language="ruby"> => <code class="language-ruby" data-language="ruby">
|
||||||
|
htmlFragment.querySelectorAll('code[data-language]').forEach(e => {
|
||||||
|
e.classList.replace(e.dataset['language'], 'language-' + e.dataset['language'])
|
||||||
|
});
|
||||||
|
|
||||||
|
return htmlFragment.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/assets/javascripts/turndown-7.2.0.min.js
vendored
Normal file
8
app/assets/javascripts/turndown-7.2.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -31,6 +31,7 @@ class JournalsController < ApplicationController
|
|||||||
helper :queries
|
helper :queries
|
||||||
helper :attachments
|
helper :attachments
|
||||||
include QueriesHelper
|
include QueriesHelper
|
||||||
|
include Redmine::QuoteReply::Builder
|
||||||
|
|
||||||
def index
|
def index
|
||||||
retrieve_query
|
retrieve_query
|
||||||
@@ -65,18 +66,11 @@ class JournalsController < ApplicationController
|
|||||||
|
|
||||||
def new
|
def new
|
||||||
@journal = Journal.visible.find(params[:journal_id]) if params[:journal_id]
|
@journal = Journal.visible.find(params[:journal_id]) if params[:journal_id]
|
||||||
if @journal
|
@content = if @journal
|
||||||
user = @journal.user
|
quote_issue_journal(@journal, indice: params[:journal_indice], partial_quote: params[:quote])
|
||||||
text = @journal.notes
|
else
|
||||||
@content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => user, :link => "#note-#{params[:journal_indice]}"})}\n> "
|
quote_issue(@issue, partial_quote: params[:quote])
|
||||||
else
|
end
|
||||||
user = @issue.author
|
|
||||||
text = @issue.description
|
|
||||||
@content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
|
|
||||||
end
|
|
||||||
# Replaces pre blocks with [...]
|
|
||||||
text = text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]')
|
|
||||||
@content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render_404
|
render_404
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class MessagesController < ApplicationController
|
|||||||
helper :watchers
|
helper :watchers
|
||||||
helper :attachments
|
helper :attachments
|
||||||
include AttachmentsHelper
|
include AttachmentsHelper
|
||||||
|
include Redmine::QuoteReply::Builder
|
||||||
|
|
||||||
REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE)
|
REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE)
|
||||||
|
|
||||||
@@ -119,12 +120,11 @@ class MessagesController < ApplicationController
|
|||||||
@subject = @message.subject
|
@subject = @message.subject
|
||||||
@subject = "RE: #{@subject}" unless @subject.starts_with?('RE:')
|
@subject = "RE: #{@subject}" unless @subject.starts_with?('RE:')
|
||||||
|
|
||||||
if @message.root == @message
|
@content = if @message.root == @message
|
||||||
@content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> "
|
quote_root_message(@message, partial_quote: params[:quote])
|
||||||
else
|
else
|
||||||
@content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => @message.author, :link => "message##{@message.id}"})}\n> "
|
quote_message(@message, partial_quote: params[:quote])
|
||||||
end
|
end
|
||||||
@content << @message.content.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
|
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { render_404 }
|
format.html { render_404 }
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
|
||||||
module JournalsHelper
|
module JournalsHelper
|
||||||
|
include Redmine::QuoteReply::Helper
|
||||||
|
|
||||||
# Returns the attachments of a journal that are displayed as thumbnails
|
# Returns the attachments of a journal that are displayed as thumbnails
|
||||||
def journal_thumbnail_attachments(journal)
|
def journal_thumbnail_attachments(journal)
|
||||||
journal.attachments.select(&:thumbnailable?)
|
journal.attachments.select(&:thumbnailable?)
|
||||||
@@ -40,13 +42,8 @@ module JournalsHelper
|
|||||||
|
|
||||||
if journal.notes.present?
|
if journal.notes.present?
|
||||||
if options[:reply_links]
|
if options[:reply_links]
|
||||||
links << link_to(icon_with_label('comment', l(:button_quote)),
|
url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
|
||||||
quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice),
|
links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
|
||||||
:remote => true,
|
|
||||||
:method => 'post',
|
|
||||||
:title => l(:button_quote),
|
|
||||||
:class => 'icon-only icon-comment'
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
if journal.editable_by?(User.current)
|
if journal.editable_by?(User.current)
|
||||||
links << link_to(icon_with_label('edit', l(:button_edit)),
|
links << link_to(icon_with_label('edit', l(:button_edit)),
|
||||||
|
|||||||
@@ -18,4 +18,5 @@
|
|||||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
|
||||||
module MessagesHelper
|
module MessagesHelper
|
||||||
|
include Redmine::QuoteReply::Helper
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<% content_for :header_tags do %>
|
||||||
|
<%= javascripts_for_quote_reply_include_tag %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= render :partial => 'action_menu' %>
|
<%= render :partial => 'action_menu' %>
|
||||||
|
|
||||||
<h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %>
|
<h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %>
|
||||||
@@ -84,11 +88,11 @@ end %>
|
|||||||
<hr />
|
<hr />
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<div class="contextual">
|
<div class="contextual">
|
||||||
<%= link_to icon_with_label('comment', l(:button_quote)), quoted_issue_path(@issue), :remote => true, :method => 'post', :class => 'icon icon-comment ' if @issue.notes_addable? %>
|
<%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p><strong><%=l(:field_description)%></strong></p>
|
<p><strong><%=l(:field_description)%></strong></p>
|
||||||
<div class="wiki">
|
<div id="issue_description_wiki" class="wiki">
|
||||||
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
|
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
<% content_for :header_tags do %>
|
||||||
|
<%= javascripts_for_quote_reply_include_tag %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= board_breadcrumb(@message) %>
|
<%= board_breadcrumb(@message) %>
|
||||||
|
|
||||||
<div class="contextual">
|
<div class="contextual">
|
||||||
<%= watcher_link(@topic, User.current) %>
|
<%= watcher_link(@topic, User.current) %>
|
||||||
<%= link_to(
|
<%= quote_reply(
|
||||||
icon_with_label('comment', l(:button_quote)),
|
url_for(:action => 'quote', :id => @topic, :format => 'js'),
|
||||||
{:action => 'quote', :id => @topic},
|
'#message_topic_wiki'
|
||||||
:method => 'get',
|
) if !@topic.locked? && authorize_for('messages', 'reply') %>
|
||||||
:class => 'icon icon-comment',
|
|
||||||
:remote => true) if !@topic.locked? && authorize_for('messages', 'reply') %>
|
|
||||||
<%= link_to(
|
<%= link_to(
|
||||||
icon_with_label('edit', l(:button_edit)),
|
icon_with_label('edit', l(:button_edit)),
|
||||||
{:action => 'edit', :id => @topic},
|
{:action => 'edit', :id => @topic},
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
|
|
||||||
<div class="message">
|
<div class="message">
|
||||||
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
|
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
|
||||||
<div class="wiki">
|
<div id="message_topic_wiki" class="wiki">
|
||||||
<%= textilizable(@topic, :content) %>
|
<%= textilizable(@topic, :content) %>
|
||||||
</div>
|
</div>
|
||||||
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
|
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
|
||||||
@@ -42,13 +44,10 @@
|
|||||||
<% @replies.each do |message| %>
|
<% @replies.each do |message| %>
|
||||||
<div class="message reply" id="<%= "message-#{message.id}" %>">
|
<div class="message reply" id="<%= "message-#{message.id}" %>">
|
||||||
<div class="contextual">
|
<div class="contextual">
|
||||||
<%= link_to(
|
<%= quote_reply(
|
||||||
icon_with_label('comment', l(:button_quote), icon_only: true),
|
url_for(:action => 'quote', :id => message, :format => 'js'),
|
||||||
{:action => 'quote', :id => message},
|
"#message-#{message.id} .wiki",
|
||||||
:remote => true,
|
icon_only: true
|
||||||
:method => 'get',
|
|
||||||
:title => l(:button_quote),
|
|
||||||
:class => 'icon icon-comment'
|
|
||||||
) if !@topic.locked? && authorize_for('messages', 'reply') %>
|
) if !@topic.locked? && authorize_for('messages', 'reply') %>
|
||||||
<%= link_to(
|
<%= link_to(
|
||||||
icon_with_label('edit', l(:button_edit), icon_only: true),
|
icon_with_label('edit', l(:button_edit), icon_only: true),
|
||||||
|
|||||||
87
lib/redmine/quote_reply.rb
Normal file
87
lib/redmine/quote_reply.rb
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Redmine - project management software
|
||||||
|
# Copyright (C) 2006- Jean-Philippe Lang
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
|
||||||
|
module Redmine
|
||||||
|
module QuoteReply
|
||||||
|
module Helper
|
||||||
|
def javascripts_for_quote_reply_include_tag
|
||||||
|
javascript_include_tag 'turndown-7.2.0.min', 'quote_reply'
|
||||||
|
end
|
||||||
|
|
||||||
|
def quote_reply(url, selector_for_content, icon_only: false)
|
||||||
|
quote_reply_function = "quoteReply('#{j url}', '#{j selector_for_content}', '#{j Setting.text_formatting}')"
|
||||||
|
|
||||||
|
html_options = { class: 'icon icon-comment' }
|
||||||
|
html_options[:title] = l(:button_quote) if icon_only
|
||||||
|
|
||||||
|
link_to_function(
|
||||||
|
icon_with_label('comment', l(:button_quote), icon_only: icon_only),
|
||||||
|
quote_reply_function,
|
||||||
|
html_options
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module Builder
|
||||||
|
def quote_issue(issue, partial_quote: nil)
|
||||||
|
user = issue.author
|
||||||
|
|
||||||
|
build_quote(
|
||||||
|
"#{ll(Setting.default_language, :text_user_wrote, user)}\n> ",
|
||||||
|
issue.description,
|
||||||
|
partial_quote
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def quote_issue_journal(journal, indice:, partial_quote: nil)
|
||||||
|
user = journal.user
|
||||||
|
|
||||||
|
build_quote(
|
||||||
|
"#{ll(Setting.default_language, :text_user_wrote_in, {value: journal.user, link: "#note-#{indice}"})}\n> ",
|
||||||
|
journal.notes,
|
||||||
|
partial_quote
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def quote_root_message(message, partial_quote: nil)
|
||||||
|
build_quote(
|
||||||
|
"#{ll(Setting.default_language, :text_user_wrote, message.author)}\n> ",
|
||||||
|
message.content,
|
||||||
|
partial_quote
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def quote_message(message, partial_quote: nil)
|
||||||
|
build_quote(
|
||||||
|
"#{ll(Setting.default_language, :text_user_wrote_in, {value: message.author, link: "message##{message.id}"})}\n> ",
|
||||||
|
message.content,
|
||||||
|
partial_quote
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_quote(quote_header, text, partial_quote = nil)
|
||||||
|
quote_text = partial_quote.presence || text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]')
|
||||||
|
|
||||||
|
"#{quote_header}#{quote_text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -168,7 +168,7 @@ class JournalsControllerTest < Redmine::ControllerTest
|
|||||||
|
|
||||||
def test_reply_to_issue
|
def test_reply_to_issue
|
||||||
@request.session[:user_id] = 2
|
@request.session[:user_id] = 2
|
||||||
get(:new, :params => {:id => 6}, :xhr => true)
|
post(:new, :params => {:id => 6}, :xhr => true)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
||||||
assert_equal 'text/javascript', response.media_type
|
assert_equal 'text/javascript', response.media_type
|
||||||
@@ -177,13 +177,13 @@ class JournalsControllerTest < Redmine::ControllerTest
|
|||||||
|
|
||||||
def test_reply_to_issue_without_permission
|
def test_reply_to_issue_without_permission
|
||||||
@request.session[:user_id] = 7
|
@request.session[:user_id] = 7
|
||||||
get(:new, :params => {:id => 6}, :xhr => true)
|
post(:new, :params => {:id => 6}, :xhr => true)
|
||||||
assert_response :forbidden
|
assert_response :forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_reply_to_note
|
def test_reply_to_note
|
||||||
@request.session[:user_id] = 2
|
@request.session[:user_id] = 2
|
||||||
get(
|
post(
|
||||||
:new,
|
:new,
|
||||||
:params => {
|
:params => {
|
||||||
:id => 6,
|
:id => 6,
|
||||||
@@ -202,7 +202,7 @@ class JournalsControllerTest < Redmine::ControllerTest
|
|||||||
journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true)
|
journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true)
|
||||||
@request.session[:user_id] = 2
|
@request.session[:user_id] = 2
|
||||||
|
|
||||||
get(
|
post(
|
||||||
:new,
|
:new,
|
||||||
:params => {
|
:params => {
|
||||||
:id => 2,
|
:id => 2,
|
||||||
@@ -215,7 +215,7 @@ class JournalsControllerTest < Redmine::ControllerTest
|
|||||||
assert_include '> Privates notes', response.body
|
assert_include '> Privates notes', response.body
|
||||||
|
|
||||||
Role.find(1).remove_permission! :view_private_notes
|
Role.find(1).remove_permission! :view_private_notes
|
||||||
get(
|
post(
|
||||||
:new,
|
:new,
|
||||||
:params => {
|
:params => {
|
||||||
:id => 2,
|
:id => 2,
|
||||||
@@ -226,6 +226,30 @@ class JournalsControllerTest < Redmine::ControllerTest
|
|||||||
assert_response :not_found
|
assert_response :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_reply_to_issue_with_partial_quote
|
||||||
|
@request.session[:user_id] = 2
|
||||||
|
|
||||||
|
params = { id: 6, quote: 'a private subproject of cookbook' }
|
||||||
|
post :new, params: params, xhr: true
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_equal 'text/javascript', response.media_type
|
||||||
|
assert_include 'John Smith wrote:', response.body
|
||||||
|
assert_include '> a private subproject of cookbook', response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reply_to_note_with_partial_quote
|
||||||
|
@request.session[:user_id] = 2
|
||||||
|
|
||||||
|
params = { id: 6, journal_id: 4, journal_indice: 1, quote: 'a private version' }
|
||||||
|
post :new, params: params, xhr: true
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_equal 'text/javascript', response.media_type
|
||||||
|
assert_include 'Redmine Admin wrote in #note-1:', response.body
|
||||||
|
assert_include '> a private version', response.body
|
||||||
|
end
|
||||||
|
|
||||||
def test_edit_xhr
|
def test_edit_xhr
|
||||||
@request.session[:user_id] = 1
|
@request.session[:user_id] = 1
|
||||||
get(:edit, :params => {:id => 2}, :xhr => true)
|
get(:edit, :params => {:id => 2}, :xhr => true)
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ class MessagesControllerTest < Redmine::ControllerTest
|
|||||||
|
|
||||||
def test_quote_if_message_is_root
|
def test_quote_if_message_is_root
|
||||||
@request.session[:user_id] = 2
|
@request.session[:user_id] = 2
|
||||||
get(
|
post(
|
||||||
:quote,
|
:quote,
|
||||||
:params => {
|
:params => {
|
||||||
:board_id => 1,
|
:board_id => 1,
|
||||||
@@ -306,7 +306,7 @@ class MessagesControllerTest < Redmine::ControllerTest
|
|||||||
|
|
||||||
def test_quote_if_message_is_not_root
|
def test_quote_if_message_is_not_root
|
||||||
@request.session[:user_id] = 2
|
@request.session[:user_id] = 2
|
||||||
get(
|
post(
|
||||||
:quote,
|
:quote,
|
||||||
:params => {
|
:params => {
|
||||||
:board_id => 1,
|
:board_id => 1,
|
||||||
@@ -322,9 +322,38 @@ class MessagesControllerTest < Redmine::ControllerTest
|
|||||||
assert_include '> An other reply', response.body
|
assert_include '> An other reply', response.body
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_quote_with_partial_quote_if_message_is_root
|
||||||
|
@request.session[:user_id] = 2
|
||||||
|
|
||||||
|
params = { board_id: 1, id: 1,
|
||||||
|
quote: "the very first post\nin the forum" }
|
||||||
|
post :quote, params: params, xhr: true
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_equal 'text/javascript', response.media_type
|
||||||
|
|
||||||
|
assert_include 'RE: First post', response.body
|
||||||
|
assert_include "Redmine Admin wrote:", response.body
|
||||||
|
assert_include '> the very first post\n> in the forum', response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_quote_with_partial_quote_if_message_is_not_root
|
||||||
|
@request.session[:user_id] = 2
|
||||||
|
|
||||||
|
params = { board_id: 1, id: 3, quote: 'other reply' }
|
||||||
|
post :quote, params: params, xhr: true
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_equal 'text/javascript', response.media_type
|
||||||
|
|
||||||
|
assert_include 'RE: First post', response.body
|
||||||
|
assert_include 'John Smith wrote in message#3:', response.body
|
||||||
|
assert_include '> other reply', response.body
|
||||||
|
end
|
||||||
|
|
||||||
def test_quote_as_html_should_respond_with_404
|
def test_quote_as_html_should_respond_with_404
|
||||||
@request.session[:user_id] = 2
|
@request.session[:user_id] = 2
|
||||||
get(
|
post(
|
||||||
:quote,
|
:quote,
|
||||||
:params => {
|
:params => {
|
||||||
:board_id => 1,
|
:board_id => 1,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class JournalsHelperTest < Redmine::HelperTest
|
|||||||
journals = issue.visible_journals_with_index # add indice
|
journals = issue.visible_journals_with_index # add indice
|
||||||
journal_actions = render_journal_actions(issue, journals.first, {reply_links: true})
|
journal_actions = render_journal_actions(issue, journals.first, {reply_links: true})
|
||||||
|
|
||||||
assert_select_in journal_actions, 'a[title=?][class="icon-only icon-comment"]', 'Quote'
|
assert_select_in journal_actions, 'a[title=?][class="icon icon-comment"]', 'Quote'
|
||||||
assert_select_in journal_actions, 'a[title=?][class="icon-only icon-edit"]', 'Edit'
|
assert_select_in journal_actions, 'a[title=?][class="icon-only icon-edit"]', 'Edit'
|
||||||
assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-del"]'
|
assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-del"]'
|
||||||
assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-copy-link"]'
|
assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-copy-link"]'
|
||||||
|
|||||||
262
test/system/issues_reply_test.rb
Normal file
262
test/system/issues_reply_test.rb
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Redmine - project management software
|
||||||
|
# Copyright (C) 2006- Jean-Philippe Lang
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
|
||||||
|
require_relative '../application_system_test_case'
|
||||||
|
|
||||||
|
class IssuesReplyTest < ApplicationSystemTestCase
|
||||||
|
fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
|
||||||
|
:trackers, :projects_trackers, :enabled_modules,
|
||||||
|
:issue_statuses, :issues, :issue_categories,
|
||||||
|
:enumerations, :custom_fields, :custom_values, :custom_fields_trackers,
|
||||||
|
:watchers, :journals, :journal_details, :versions,
|
||||||
|
:workflows
|
||||||
|
|
||||||
|
def test_reply_to_issue
|
||||||
|
with_text_formatting 'common_mark' do
|
||||||
|
within '.issue.details' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Select the other than the issue description element.
|
||||||
|
page.execute_script <<-JS
|
||||||
|
const range = document.createRange();
|
||||||
|
// Select "Description" text.
|
||||||
|
range.selectNodeContents(document.querySelector('.description > p'))
|
||||||
|
|
||||||
|
window.getSelection().addRange(range);
|
||||||
|
JS
|
||||||
|
|
||||||
|
assert_field 'issue_notes', with: <<~TEXT
|
||||||
|
John Smith wrote:
|
||||||
|
> Unable to print recipes
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
assert_selector :css, '#issue_notes:focus'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reply_to_note
|
||||||
|
with_text_formatting 'textile' do
|
||||||
|
within '#change-1' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_field 'issue_notes', with: <<~TEXT
|
||||||
|
Redmine Admin wrote in #note-1:
|
||||||
|
> Journal notes
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
assert_selector :css, '#issue_notes:focus'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reply_to_issue_with_partial_quote
|
||||||
|
with_text_formatting 'common_mark' do
|
||||||
|
assert_text 'Unable to print recipes'
|
||||||
|
|
||||||
|
# Select only the "print" text from the text "Unable to print recipes" in the description.
|
||||||
|
page.execute_script <<-JS
|
||||||
|
const range = document.createRange();
|
||||||
|
const wiki = document.querySelector('#issue_description_wiki > p').childNodes[0];
|
||||||
|
range.setStart(wiki, 10);
|
||||||
|
range.setEnd(wiki, 15);
|
||||||
|
|
||||||
|
window.getSelection().addRange(range);
|
||||||
|
JS
|
||||||
|
|
||||||
|
within '.issue.details' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_field 'issue_notes', with: <<~TEXT
|
||||||
|
John Smith wrote:
|
||||||
|
> print
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
assert_selector :css, '#issue_notes:focus'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reply_to_note_with_partial_quote
|
||||||
|
with_text_formatting 'textile' do
|
||||||
|
assert_text 'Journal notes'
|
||||||
|
|
||||||
|
# Select the entire details of the note#1 and the part of the note#1's text.
|
||||||
|
page.execute_script <<-JS
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStartBefore(document.querySelector('#change-1 .details'));
|
||||||
|
// Select only the text "Journal" from the text "Journal notes" in the note-1.
|
||||||
|
range.setEnd(document.querySelector('#change-1 .wiki > p').childNodes[0], 7);
|
||||||
|
|
||||||
|
window.getSelection().addRange(range);
|
||||||
|
JS
|
||||||
|
|
||||||
|
within '#change-1' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_field 'issue_notes', with: <<~TEXT
|
||||||
|
Redmine Admin wrote in #note-1:
|
||||||
|
> Journal
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
assert_selector :css, '#issue_notes:focus'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_partial_quotes_should_be_quoted_in_plain_text_when_text_format_is_textile
|
||||||
|
issues(:issues_001).update!(description: <<~DESC)
|
||||||
|
# "Redmine":https://redmine.org is
|
||||||
|
# a *flexible* project management
|
||||||
|
# web application.
|
||||||
|
DESC
|
||||||
|
|
||||||
|
with_text_formatting 'textile' do
|
||||||
|
assert_text /a flexible project management/
|
||||||
|
|
||||||
|
# Select the entire description of the issue.
|
||||||
|
page.execute_script <<-JS
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(document.querySelector('#issue_description_wiki'))
|
||||||
|
window.getSelection().addRange(range);
|
||||||
|
JS
|
||||||
|
|
||||||
|
within '.issue.details' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
expected_value = [
|
||||||
|
'John Smith wrote:',
|
||||||
|
'> Redmine is',
|
||||||
|
'> a flexible project management',
|
||||||
|
'> web application.',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
]
|
||||||
|
assert_equal expected_value.join("\n"), find_field('issue_notes').value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_partial_quotes_should_be_quoted_in_common_mark_format_when_text_format_is_common_mark
|
||||||
|
issues(:issues_001).update!(description: <<~DESC)
|
||||||
|
# Title1
|
||||||
|
[Redmine](https://redmine.org) is a **flexible** project management web application.
|
||||||
|
|
||||||
|
## Title2
|
||||||
|
* List1
|
||||||
|
* List1-1
|
||||||
|
* List2
|
||||||
|
|
||||||
|
1. Number1
|
||||||
|
1. Number2
|
||||||
|
|
||||||
|
### Title3
|
||||||
|
```ruby
|
||||||
|
puts "Hello, world!"
|
||||||
|
```
|
||||||
|
```
|
||||||
|
$ bin/rails db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
| Subject1 | Subject2 |
|
||||||
|
| -------- | -------- |
|
||||||
|
| ~~cell1~~| **cell2**|
|
||||||
|
|
||||||
|
* [ ] Checklist1
|
||||||
|
* [x] Checklist2
|
||||||
|
|
||||||
|
[[WikiPage]]
|
||||||
|
Issue #14
|
||||||
|
Issue ##2
|
||||||
|
|
||||||
|
Redmine is `a flexible` project management
|
||||||
|
|
||||||
|
web application.
|
||||||
|
DESC
|
||||||
|
|
||||||
|
with_text_formatting 'common_mark' do
|
||||||
|
assert_text /Title1/
|
||||||
|
|
||||||
|
# Select the entire description of the issue.
|
||||||
|
page.execute_script <<-JS
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(document.querySelector('#issue_description_wiki'))
|
||||||
|
window.getSelection().addRange(range);
|
||||||
|
JS
|
||||||
|
|
||||||
|
within '.issue.details' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
expected_value = [
|
||||||
|
'John Smith wrote:',
|
||||||
|
'> # Title1',
|
||||||
|
'> ',
|
||||||
|
'> [Redmine](https://redmine.org) is a **flexible** project management web application.',
|
||||||
|
'> ',
|
||||||
|
'> ## Title2',
|
||||||
|
'> ',
|
||||||
|
'> * List1',
|
||||||
|
'> * List1-1',
|
||||||
|
'> * List2',
|
||||||
|
'> ',
|
||||||
|
'> 1. Number1',
|
||||||
|
'> 2. Number2',
|
||||||
|
'> ',
|
||||||
|
'> ### Title3',
|
||||||
|
'> ',
|
||||||
|
'> ```ruby',
|
||||||
|
'> puts "Hello, world!"',
|
||||||
|
'> ```',
|
||||||
|
'> ',
|
||||||
|
'> ```',
|
||||||
|
'> $ bin/rails db:migrate',
|
||||||
|
'> ```',
|
||||||
|
'> ',
|
||||||
|
'> Subject1 Subject2',
|
||||||
|
'> ~~cell1~~ **cell2**',
|
||||||
|
'> ',
|
||||||
|
'> * [ ] Checklist1',
|
||||||
|
'> * [x] Checklist2',
|
||||||
|
'> ',
|
||||||
|
'> [WikiPage](/projects/ecookbook/wiki/WikiPage) ',
|
||||||
|
'> Issue [#14](/issues/14 "Bug: Private issue on public project (New)") ',
|
||||||
|
'> Issue [Feature request #2: Add ingredients categories](/issues/2 "Status: Assigned")',
|
||||||
|
'> ',
|
||||||
|
'> Redmine is `a flexible` project management',
|
||||||
|
'> ',
|
||||||
|
'> web application.',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
]
|
||||||
|
assert_equal expected_value.join("\n"), find_field('issue_notes').value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def with_text_formatting(format)
|
||||||
|
with_settings text_formatting: format do
|
||||||
|
log_user('jsmith', 'jsmith')
|
||||||
|
visit '/issues/1'
|
||||||
|
|
||||||
|
yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
119
test/system/messages_test.rb
Normal file
119
test/system/messages_test.rb
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Redmine - project management software
|
||||||
|
# Copyright (C) 2006- Jean-Philippe Lang
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
|
||||||
|
require_relative '../application_system_test_case'
|
||||||
|
|
||||||
|
class MessagesTest < ApplicationSystemTestCase
|
||||||
|
fixtures :projects, :users, :roles, :members, :member_roles,
|
||||||
|
:enabled_modules, :enumerations,
|
||||||
|
:custom_fields, :custom_values, :custom_fields_trackers,
|
||||||
|
:watchers, :boards, :messages
|
||||||
|
|
||||||
|
def test_reply_to_topic_message
|
||||||
|
with_text_formatting 'common_mark' do
|
||||||
|
within '#content > .contextual' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_field 'message_content', with: <<~TEXT
|
||||||
|
Redmine Admin wrote:
|
||||||
|
> This is the very first post
|
||||||
|
> in the forum
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reply_to_message
|
||||||
|
with_text_formatting 'textile' do
|
||||||
|
within '#message-2' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_field 'message_content', with: <<~TEXT
|
||||||
|
Redmine Admin wrote in message#2:
|
||||||
|
> Reply to the first post
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reply_to_topic_message_with_partial_quote
|
||||||
|
with_text_formatting 'textile' do
|
||||||
|
assert_text /This is the very first post/
|
||||||
|
|
||||||
|
# Select the part of the topic message through the entire text of the attachment below it.
|
||||||
|
page.execute_script <<-'JS'
|
||||||
|
const range = document.createRange();
|
||||||
|
const message = document.querySelector('#message_topic_wiki');
|
||||||
|
// Select only the text "in the forum" from the text "This is the very first post\nin the forum".
|
||||||
|
range.setStartBefore(message.querySelector('p').childNodes[2]);
|
||||||
|
range.setEndAfter(message.parentNode.querySelector('.attachments'));
|
||||||
|
|
||||||
|
window.getSelection().addRange(range);
|
||||||
|
JS
|
||||||
|
|
||||||
|
within '#content > .contextual' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_field 'message_content', with: <<~TEXT
|
||||||
|
Redmine Admin wrote:
|
||||||
|
> in the forum
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reply_to_message_with_partial_quote
|
||||||
|
with_text_formatting 'common_mark' do
|
||||||
|
assert_text 'Reply to the first post'
|
||||||
|
|
||||||
|
# Select the entire message, including the subject and headers of messages #2 and #3.
|
||||||
|
page.execute_script <<-JS
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStartBefore(document.querySelector('#message-2'));
|
||||||
|
range.setEndAfter(document.querySelector('#message-3'));
|
||||||
|
|
||||||
|
window.getSelection().addRange(range);
|
||||||
|
JS
|
||||||
|
|
||||||
|
within '#message-2' do
|
||||||
|
click_link 'Quote'
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_field 'message_content', with: <<~TEXT
|
||||||
|
Redmine Admin wrote in message#2:
|
||||||
|
> Reply to the first post
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def with_text_formatting(format)
|
||||||
|
with_settings text_formatting: format do
|
||||||
|
log_user('jsmith', 'jsmith')
|
||||||
|
visit '/boards/1/topics/1'
|
||||||
|
|
||||||
|
yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
40
test/unit/lib/redmine/quote_reply_helper_test.rb
Normal file
40
test/unit/lib/redmine/quote_reply_helper_test.rb
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Redmine - project management software
|
||||||
|
# Copyright (C) 2006- Jean-Philippe Lang
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
|
||||||
|
require_relative '../../../test_helper'
|
||||||
|
|
||||||
|
class QuoteReplyHelperTest < ActionView::TestCase
|
||||||
|
include ERB::Util
|
||||||
|
include Redmine::QuoteReply::Helper
|
||||||
|
|
||||||
|
fixtures :issues
|
||||||
|
|
||||||
|
def test_quote_reply
|
||||||
|
url = quoted_issue_path(issues(:issues_001))
|
||||||
|
|
||||||
|
a_tag = quote_reply(url, '#issue_description_wiki')
|
||||||
|
assert_includes a_tag, %|onclick="#{h "quoteReply('/issues/1/quoted', '#issue_description_wiki', 'common_mark'); return false;"}"|
|
||||||
|
assert_includes a_tag, %|class="icon icon-comment"|
|
||||||
|
assert_not_includes a_tag, 'title='
|
||||||
|
|
||||||
|
# When icon_only is true
|
||||||
|
a_tag = quote_reply(url, '#issue_description_wiki', icon_only: true)
|
||||||
|
assert_includes a_tag, %|title="Quote"|
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user