Reimplement partial quote feature using Stimulus JS (#42515).

Patch by Katsuya HIDAKA (user:hidakatsuya).

git-svn-id: https://svn.redmine.org/redmine/trunk@23854 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Marius Balteanu
2025-07-04 05:45:29 +00:00
parent c1115fea4a
commit a531b4fe80
12 changed files with 188 additions and 84 deletions

View File

@@ -22,6 +22,7 @@ gem 'commonmarker', '~> 2.3.0'
gem "doorkeeper", "~> 5.8.2"
gem "bcrypt", require: false
gem "doorkeeper-i18n", "~> 5.2"
gem "requestjs-rails", "~> 0.0.13"
# Ruby Standard Gems
gem 'csv', '~> 3.3.2'

View File

@@ -1,21 +1,6 @@
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) }
});
}
import { Controller } from '@hotwired/stimulus'
import TurndownService from 'turndown'
import { post } from '@rails/request.js'
class QuoteExtractor {
static extract(targetElement) {
@@ -214,3 +199,26 @@ class QuoteCommonMarkFormatter {
return htmlFragment.innerHTML;
}
}
export default class extends Controller {
static targets = [ 'content' ];
quote(event) {
const { url, textFormatting } = event.params;
const selectedRange = QuoteExtractor.extract(this.contentTarget);
let formatter;
if (textFormatting === 'common_mark') {
formatter = new QuoteCommonMarkFormatter();
} else {
formatter = new QuoteTextFormatter();
}
post(url, {
body: JSON.stringify({ quote: formatter.format(selectedRange) }),
contentType: 'application/json',
responseKind: 'script'
});
}
}

File diff suppressed because one or more lines are too long

View File

@@ -46,7 +46,7 @@ module JournalsHelper
if journal.notes.present?
if options[:reply_links]
url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
links << quote_reply_button(url: url, icon_only: true)
end
if journal.editable_by?(User.current)
links << link_to(sprite_icon('edit', l(:button_edit)),
@@ -69,7 +69,8 @@ module JournalsHelper
end
def render_notes(issue, journal, options={})
content_tag('div', textilizable(journal, :notes), :id => "journal-#{journal.id}-notes", :class => "wiki")
content_tag('div', textilizable(journal, :notes),
id: "journal-#{journal.id}-notes", class: "wiki", data: { quote_reply_target: 'content' })
end
def render_private_notes_indicator(journal)

View File

@@ -1,7 +1,3 @@
<% content_for :header_tags do %>
<%= javascripts_for_quote_reply_include_tag %>
<% end %>
<%= render :partial => 'action_menu' %>
<h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %>
@@ -96,13 +92,13 @@ end %>
<% if @issue.description? %>
<hr />
<div class="description">
<div class="description" data-controller="quote-reply">
<div class="contextual">
<%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>
<%= quote_reply_button(url: quoted_issue_path(@issue)) if @issue.notes_addable? %>
</div>
<p><strong><%=l(:field_description)%></strong></p>
<div id="issue_description_wiki" class="wiki">
<div id="issue_description_wiki" class="wiki" data-quote-reply-target="content">
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
</div>
</div>

View File

@@ -5,7 +5,7 @@
<% reply_links = issue.notes_addable? -%>
<% for journal in journals %>
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>">
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>" data-controller="quote-reply">
<div id="note-<%= journal.indice %>" class="note">
<div class="contextual">
<span class="journal-actions"><%= render_journal_actions(issue, journal, :reply_links => reply_links) %></span>

View File

@@ -1,14 +1,10 @@
<% content_for :header_tags do %>
<%= javascripts_for_quote_reply_include_tag %>
<% end %>
<%= board_breadcrumb(@message) %>
<div class="contextual">
<div data-controller="quote-reply">
<div class="contextual">
<%= watcher_link(@topic, User.current) %>
<%= quote_reply(
url_for(:action => 'quote', :id => @topic, :format => 'js'),
'#message_topic_wiki'
<%= quote_reply_button(
url: url_for(action: 'quote', id: @topic, format: 'js')
) if !@topic.locked? && authorize_for('messages', 'reply') %>
<%= link_to(
sprite_icon('edit', l(:button_edit)),
@@ -21,20 +17,21 @@
:method => :post,
:data => {:confirm => l(:text_are_you_sure)},
:class => 'icon icon-del'
) if @message.destroyable_by?(User.current) %>
</div>
) if @message.destroyable_by?(User.current) %>
</div>
<h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
<h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
<div class="message">
<div class="reaction">
<%= reaction_button @topic %>
</div>
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
<div id="message_topic_wiki" class="wiki">
<%= textilizable(@topic, :content) %>
</div>
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
<div class="message">
<div class="reaction">
<%= reaction_button @topic %>
</div>
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
<div id="message_topic_wiki" class="wiki" data-quote-reply-target="content">
<%= textilizable(@topic, :content) %>
</div>
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
</div>
</div>
<br />
@@ -45,12 +42,11 @@
<p><%= toggle_link l(:button_reply), "reply", :focus => 'message_content', :scroll => "message_content" %></p>
<% end %>
<% @replies.each do |message| %>
<div class="message reply" id="<%= "message-#{message.id}" %>">
<div class="message reply" id="<%= "message-#{message.id}" %>" data-controller="quote-reply">
<div class="contextual">
<%= reaction_button message %>
<%= quote_reply(
url_for(:action => 'quote', :id => message, :format => 'js'),
"#message-#{message.id} .wiki",
<%= quote_reply_button(
url: url_for(action: 'quote', id: message, format: 'js'),
icon_only: true
) if !@topic.locked? && authorize_for('messages', 'reply') %>
<%= link_to(
@@ -74,7 +70,9 @@
-
<%= authoring message.created_on, message.author %>
</h4>
<div class="wiki"><%= textilizable message, :content, :attachments => message.attachments %></div>
<div class="wiki" data-quote-reply-target="content">
<%= textilizable message, :content, :attachments => message.attachments %>
</div>
<%= link_to_attachments message, :author => false, :thumbnails => true %>
</div>
<% end %>

View File

@@ -5,4 +5,5 @@
pin "application"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "turndown" # @7.2.0
pin_all_from "app/javascript/controllers", under: "controllers"

View File

@@ -20,21 +20,18 @@
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_button(url:, icon_only: false)
button_params = {
data: {
action: 'quote-reply#quote',
quote_reply_url_param: url,
quote_reply_text_formatting_param: Setting.text_formatting
},
class: "#{icon_only ? "icon-only" : "icon"} icon-quote"
}
button_params[:title] = l(:button_quote) if icon_only
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_only ? "icon-only" : "icon"} icon-quote" }
html_options[:title] = l(:button_quote) if icon_only
link_to_function(
sprite_icon('quote-filled', l(:button_quote), icon_only: icon_only, style: :filled),
quote_reply_function,
html_options
)
link_to sprite_icon('quote-filled', l(:button_quote), icon_only: icon_only, style: :filled), '#', button_params
end
end

View File

@@ -22,7 +22,7 @@ require_relative '../application_system_test_case'
class MessagesTest < ApplicationSystemTestCase
def test_reply_to_topic_message
with_text_formatting 'common_mark' do
within '#content > .contextual' do
within '#content > [data-controller="quote-reply"]' do
click_link 'Quote'
end
@@ -64,7 +64,7 @@ class MessagesTest < ApplicationSystemTestCase
window.getSelection().addRange(range);
JS
within '#content > .contextual' do
within '#content > [data-controller="quote-reply"]' do
click_link 'Quote'
end

View File

@@ -23,18 +23,18 @@ class QuoteReplyHelperTest < ActionView::TestCase
include ERB::Util
include Redmine::QuoteReply::Helper
def test_quote_reply
def test_quote_reply_button
with_locale 'en' do
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-quote"|
assert_not_includes a_tag, 'title='
html = quote_reply_button(url: url)
assert_select_in html,
'a[data-quote-reply-url-param=?][data-quote-reply-text-formatting-param=?]:not([title])',
url, Setting.text_formatting
# When icon_only is true
a_tag = quote_reply(url, '#issue_description_wiki', icon_only: true)
assert_includes a_tag, %|title="Quote"|
html = quote_reply_button(url: url, icon_only: true)
assert_select_in html, 'a.icon-only.icon-quote[title=?]', 'Quote'
end
end
end

110
vendor/javascript/turndown.js vendored Normal file

File diff suppressed because one or more lines are too long