mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #23337 from opf/bug/74762-numeric-id-in-the-email-notification-after-adding-watchers
bug/74762 Numeric ID in the email notification after adding watchers
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
# Pin the external rendering channel so mailer templates never have to
|
||||
# remember the `render_mode:` / `only_path:` / `static_html:` combination.
|
||||
# Matching the `.html.erb` / `.text.erb` extension to the helper name keeps
|
||||
# caller intent visible.
|
||||
module MailFormattingHelper
|
||||
def format_mail_html(*, **)
|
||||
format_text(*, render_mode: :external_html, **)
|
||||
end
|
||||
|
||||
def format_mail_text(*, **)
|
||||
format_text(*, render_mode: :external_text, **)
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,7 @@ class ApplicationMailer < ActionMailer::Base
|
||||
helper :application, # for format_text
|
||||
:work_packages, # for css classes
|
||||
:custom_fields, # for show_value
|
||||
:mail_formatting, # for format_mail_html / format_mail_text
|
||||
:mail_layout # for layouting
|
||||
|
||||
include OpenProject::LocaleHelper
|
||||
|
||||
@@ -121,6 +121,13 @@ module WorkPackage::SemanticIdentifier
|
||||
end
|
||||
end
|
||||
|
||||
# Returns formatted value for inline UI display.
|
||||
# * Semantic mode: "PROJ-42" (no prefix — self-describing)
|
||||
# * Classic mode: "#42" (hash-prefixed)
|
||||
def self.format_display_id(display_id)
|
||||
display_id.is_a?(String) && display_id.match?(/[A-Za-z]/) ? display_id : "##{display_id}"
|
||||
end
|
||||
|
||||
# Returns the user-facing identifier for this work package.
|
||||
# In semantic mode: the project-based identifier (e.g. "PROJ-42")
|
||||
# In classic mode: the numeric database ID
|
||||
@@ -134,8 +141,7 @@ module WorkPackage::SemanticIdentifier
|
||||
# Semantic mode: "PROJ-42" (no prefix — self-describing)
|
||||
# Classic mode: "#42" (hash-prefixed)
|
||||
def formatted_id
|
||||
did = display_id
|
||||
did.is_a?(String) && did.match?(/[A-Za-z]/) ? did : "##{did}"
|
||||
WorkPackage::SemanticIdentifier.format_display_id(display_id)
|
||||
end
|
||||
|
||||
# Override ActiveRecord's default `to_param` so Rails URL helpers
|
||||
|
||||
@@ -123,7 +123,8 @@ class WorkPackages::UpdateAncestorsService < BaseServices::BaseCallable
|
||||
|
||||
def set_journal_note(work_packages)
|
||||
work_packages.each do |wp|
|
||||
wp.journal_notes = I18n.t("work_package.updated_automatically_by_child_changes", child: "##{initiator_work_package.id}")
|
||||
wp.journal_notes = I18n.t("work_package.updated_automatically_by_child_changes",
|
||||
child: "##{initiator_work_package.id}")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -47,4 +47,4 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%= format_text(work_package, :description, only_path: false) %>
|
||||
<%= format_mail_html(work_package, :description) %>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<table <%= placeholder_table_styles(width: "100%", style: "width:100%;") %>>
|
||||
<tr>
|
||||
<td style="<%= placeholder_text_styles %>">
|
||||
<%= format_text @journal.notes, only_path: false %>
|
||||
<%= format_mail_html @journal.notes %>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
<%= "=" * ((@work_package.formatted_id + " " + @work_package.subject).length + 4) %>
|
||||
|
||||
<%= I18n.t(:label_comment_added) %>:
|
||||
<%= strip_tags @journal.notes %>
|
||||
<%= format_mail_text(
|
||||
@journal.notes,
|
||||
object: @work_package,
|
||||
project: @work_package.project
|
||||
) %>
|
||||
|
||||
<%= "-" * 100 %>
|
||||
|
||||
@@ -30,9 +30,8 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<hr>
|
||||
<%= render partial: "work_package_details", locals: { work_package: @work_package } %>
|
||||
<p>
|
||||
<%= format_text(
|
||||
<%= format_mail_html(
|
||||
t(:text_latest_note, note: last_work_package_note(@work_package)),
|
||||
only_path: false,
|
||||
object: @work_package,
|
||||
project: @work_package.project
|
||||
) %>
|
||||
|
||||
@@ -31,4 +31,8 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
|
||||
----------------------------------------
|
||||
<%= render partial: "work_package_details", locals: { work_package: @work_package } %>
|
||||
<%= t(:text_latest_note, note: last_work_package_note(@work_package)) %>
|
||||
<%= format_mail_text(
|
||||
t(:text_latest_note, note: last_work_package_note(@work_package)),
|
||||
object: @work_package,
|
||||
project: @work_package.project
|
||||
) %>
|
||||
|
||||
@@ -43,20 +43,25 @@ module OpenProject
|
||||
|
||||
# @!macro format_text_params
|
||||
# @param [Project] project a Project context.
|
||||
# @param [Boolean] only_path whether to generate links with relative URLs.
|
||||
# @param [Symbol] render_mode the rendering channel (`:in_app_html`,
|
||||
# `:external_html`, `:external_text`). Resolves the `only_path`,
|
||||
# `static_html` and `plain_text` context flags as a set. Prefer this
|
||||
# over passing the primitives individually. See {RenderMode}.
|
||||
# @param [Boolean] only_path explicit override for the resolved `only_path`.
|
||||
# @param [User] current_user the current user context.
|
||||
# @param [:plain, :rich] format the text format.
|
||||
# `:plain` will return plain text.
|
||||
# `:rich` will render raw Markdown as HTML.
|
||||
# @param ** [Hash] additional context to pass to the underlying rendering
|
||||
# pipeline.
|
||||
# pipeline. Explicit `static_html:` / `plain_text:` here override the
|
||||
# values resolved from `render_mode:`.
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
|
||||
##
|
||||
# Formats text according to system settings and provided params.
|
||||
#
|
||||
# @overload format_text(text, object: nil, project: @project || object.try(:project), only_path: true, current_user: User.current, format: :rich, **)
|
||||
# @overload format_text(text, object: nil, project: @project || object.try(:project), render_mode: :in_app_html, only_path: nil, current_user: User.current, format: :rich, **)
|
||||
#
|
||||
# @param [String] text the raw text to be formatted, typically Markdown.
|
||||
# @param [Object] object an object context.
|
||||
@@ -64,10 +69,10 @@ module OpenProject
|
||||
#
|
||||
# @example Setting a project context explicitly
|
||||
# format_text("## Hello world", project: current_project)
|
||||
# @example Generating links with full URLs
|
||||
# format_text("[Projects](/projects)", only_path: false)
|
||||
# @example Rendering for an external surface (mailer, RSS, export)
|
||||
# format_text("see #42", render_mode: :external_html)
|
||||
#
|
||||
# @overload format_text(object, attribute, project: @project || object.try(:project), only_path: true, current_user: User.current, format: :rich, **)
|
||||
# @overload format_text(object, attribute, project: @project || object.try(:project), render_mode: :in_app_html, only_path: nil, current_user: User.current, format: :rich, **)
|
||||
#
|
||||
# @param [Object] object an object, typically a model
|
||||
# (i.e. `ActiveRecord::Base` descendent).
|
||||
@@ -79,7 +84,8 @@ module OpenProject
|
||||
# format_text(issue, :description, options)
|
||||
#
|
||||
# @return [String] the formatted text as an HTML-safe String.
|
||||
def format_text(*args, object: nil, project: nil, only_path: true, current_user: User.current, format: :rich, **)
|
||||
def format_text(*args, object: nil, project: nil, render_mode: :in_app_html,
|
||||
only_path: nil, current_user: User.current, format: :rich, **kwargs)
|
||||
case args.size
|
||||
when 1
|
||||
attribute = nil
|
||||
@@ -94,15 +100,22 @@ module OpenProject
|
||||
|
||||
project ||= @project || object.try(:project)
|
||||
|
||||
resolved = RenderMode.resolve(
|
||||
render_mode,
|
||||
only_path:,
|
||||
static_html: kwargs.delete(:static_html),
|
||||
plain_text: kwargs.delete(:plain_text)
|
||||
)
|
||||
|
||||
Renderer.format_text(
|
||||
text,
|
||||
**,
|
||||
**kwargs,
|
||||
format:,
|
||||
object:,
|
||||
request: try(:request),
|
||||
current_user:,
|
||||
attribute:,
|
||||
only_path:,
|
||||
**resolved,
|
||||
project:
|
||||
)
|
||||
end
|
||||
|
||||
@@ -37,6 +37,8 @@ module OpenProject::TextFormatting
|
||||
include OpenProject::StaticRouting::UrlHelpers
|
||||
|
||||
def call
|
||||
preload_mentions
|
||||
|
||||
doc.search("mention").each do |mention|
|
||||
anchor = mention_anchor(mention)
|
||||
mention.replace(anchor) if anchor
|
||||
@@ -47,6 +49,41 @@ module OpenProject::TextFormatting
|
||||
|
||||
private
|
||||
|
||||
# WP labels resolve regardless of viewer (so an inaccessible WP
|
||||
# still renders its current formatted_id); a separate id pluck
|
||||
# gates anchor-vs-text. Principals collapse the two concerns into
|
||||
# one visibility-scoped fetch — invisible users and groups fall
|
||||
# back to the literal envelope text.
|
||||
def preload_mentions
|
||||
preload_work_package_mentions
|
||||
preload_principal_mentions
|
||||
end
|
||||
|
||||
def preload_work_package_mentions
|
||||
ids = mention_ids_for("work_package")
|
||||
if ids.empty?
|
||||
@mentioned_work_packages = {}
|
||||
@visible_mentioned_ids = Set.new
|
||||
return
|
||||
end
|
||||
|
||||
scope = WorkPackage.where(id: ids)
|
||||
scope = scope.includes(:type, :status) if context[:static_html]
|
||||
@mentioned_work_packages = scope.index_by(&:id)
|
||||
@visible_mentioned_ids = WorkPackage.visible.where(id: ids).pluck(:id).to_set
|
||||
end
|
||||
|
||||
def preload_principal_mentions
|
||||
user_ids = mention_ids_for("user")
|
||||
group_ids = mention_ids_for("group")
|
||||
@mentioned_users = user_ids.empty? ? {} : User.visible.where(id: user_ids).index_by(&:id)
|
||||
@mentioned_groups = group_ids.empty? ? {} : Group.visible.where(id: group_ids).index_by(&:id)
|
||||
end
|
||||
|
||||
def mention_ids_for(type)
|
||||
doc.css(%(mention[data-type="#{type}"])).filter_map { mention_id(it)&.to_i }.uniq
|
||||
end
|
||||
|
||||
def mention_anchor(mention)
|
||||
mention_instance = class_from_mention(mention)
|
||||
|
||||
@@ -75,45 +112,61 @@ module OpenProject::TextFormatting
|
||||
end
|
||||
|
||||
def work_package_mention(work_package, mention)
|
||||
# Render the mention with the same label and URL convention used for
|
||||
# `#N` text references elsewhere in the markdown pipeline.
|
||||
display_id = work_package.display_id
|
||||
return Nokogiri::XML::Text.new(work_package.formatted_id, mention.document) if text_only?(work_package)
|
||||
|
||||
case mention.text.count("#")
|
||||
when 3
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id: work_package.id, display_id:, detailed: true }
|
||||
when 2
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id: work_package.id, display_id:, detailed: false }
|
||||
else
|
||||
link_to(work_package.formatted_id,
|
||||
work_package_path_or_url(id: display_id, only_path: context[:only_path]),
|
||||
class: "issue work_package",
|
||||
data: {
|
||||
hover_card_trigger_target: "trigger",
|
||||
hover_card_url: hover_card_work_package_path(display_id)
|
||||
})
|
||||
when 3 then work_package_quickinfo(work_package, detailed: true)
|
||||
when 2 then work_package_quickinfo(work_package, detailed: false)
|
||||
else work_package_link(work_package)
|
||||
end
|
||||
end
|
||||
|
||||
def class_from_mention(mention)
|
||||
mention_class = case mention.attributes["data-type"].value
|
||||
when "user"
|
||||
User
|
||||
when "group"
|
||||
Group
|
||||
when "work_package"
|
||||
WorkPackage
|
||||
else
|
||||
raise ArgumentError
|
||||
end
|
||||
# The hover-card endpoint a quickinfo would link to is unreachable
|
||||
# for plain-text recipients and for viewers without view permission.
|
||||
def text_only?(work_package)
|
||||
context[:plain_text] || @visible_mentioned_ids.exclude?(work_package.id)
|
||||
end
|
||||
|
||||
mention_class
|
||||
.visible
|
||||
.find_by(id: mention_id(mention)) || fallback_text(mention)
|
||||
def work_package_quickinfo(work_package, detailed:)
|
||||
return work_package_static_macro(work_package, detailed:) if context[:static_html]
|
||||
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id: work_package.id,
|
||||
display_id: work_package.display_id,
|
||||
detailed: }
|
||||
end
|
||||
|
||||
# Uses the WP's current `formatted_id` rather than the envelope text,
|
||||
# so a renamed identifier doesn't leave a stale label in the mailer.
|
||||
def work_package_static_macro(work_package, detailed:)
|
||||
label = OpenProject::TextFormatting::Helpers::StaticMacroLabel
|
||||
.call(work_package, label: work_package.formatted_id, detailed:)
|
||||
|
||||
link_to(label,
|
||||
work_package_path_or_url(id: work_package.display_id, only_path: context[:only_path]),
|
||||
class: "issue work_package")
|
||||
end
|
||||
|
||||
def work_package_link(work_package)
|
||||
display_id = work_package.display_id
|
||||
link_to(work_package.formatted_id,
|
||||
work_package_path_or_url(id: display_id, only_path: context[:only_path]),
|
||||
class: "issue work_package",
|
||||
data: {
|
||||
hover_card_trigger_target: "trigger",
|
||||
hover_card_url: hover_card_work_package_path(display_id)
|
||||
})
|
||||
end
|
||||
|
||||
def class_from_mention(mention)
|
||||
id = mention_id(mention)&.to_i
|
||||
case mention.attributes["data-type"].value
|
||||
when "user" then @mentioned_users[id]
|
||||
when "group" then @mentioned_groups[id]
|
||||
when "work_package" then @mentioned_work_packages[id]
|
||||
else raise ArgumentError
|
||||
end || fallback_text(mention)
|
||||
end
|
||||
|
||||
##
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module OpenProject::TextFormatting
|
||||
module Filters
|
||||
# Terminal stage when the rich pipeline is rendering for `text/plain`
|
||||
# bodies — collapses the DOM to its visible text so no HTML escapes.
|
||||
class PlainTextOutputFilter < HTML::Pipeline::Filter
|
||||
def call
|
||||
doc.text
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -31,11 +31,45 @@ require "task_list/filter"
|
||||
|
||||
module OpenProject::TextFormatting::Formats::Markdown
|
||||
class Formatter < OpenProject::TextFormatting::Formats::BaseFormatter
|
||||
RICH_FILTERS = [
|
||||
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
|
||||
OpenProject::TextFormatting::Filters::MarkdownFilter,
|
||||
OpenProject::TextFormatting::Filters::SanitizationFilter,
|
||||
OpenProject::TextFormatting::Filters::TaskListFilter,
|
||||
OpenProject::TextFormatting::Filters::TableOfContentsFilter,
|
||||
OpenProject::TextFormatting::Filters::MacroFilter,
|
||||
OpenProject::TextFormatting::Filters::MentionFilter,
|
||||
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
|
||||
OpenProject::TextFormatting::Filters::SyntaxHighlightFilter,
|
||||
OpenProject::TextFormatting::Filters::AttachmentFilter,
|
||||
OpenProject::TextFormatting::Filters::AutolinkFilter,
|
||||
OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter,
|
||||
OpenProject::TextFormatting::Filters::RelativeLinkFilter,
|
||||
OpenProject::TextFormatting::Filters::LinkAttributeFilter,
|
||||
OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter,
|
||||
OpenProject::TextFormatting::Filters::FigureWrappedFilter,
|
||||
OpenProject::TextFormatting::Filters::BemCssFilter
|
||||
].freeze
|
||||
|
||||
# `text/plain` mailer bodies share the matcher and mention stages so
|
||||
# work-package references resolve consistently with the HTML channel,
|
||||
# then `PlainTextOutputFilter` collapses the DOM to text. Filters that
|
||||
# only shape HTML (TOC, syntax highlight, autolink, link-attribute,
|
||||
# figure, BEM) are omitted because `doc.text` would discard their work.
|
||||
TEXT_FILTERS = [
|
||||
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
|
||||
OpenProject::TextFormatting::Filters::MarkdownFilter,
|
||||
OpenProject::TextFormatting::Filters::SanitizationFilter,
|
||||
OpenProject::TextFormatting::Filters::MentionFilter,
|
||||
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
|
||||
OpenProject::TextFormatting::Filters::PlainTextOutputFilter
|
||||
].freeze
|
||||
|
||||
def to_html(text)
|
||||
result = pipeline.call(text, context)
|
||||
output = result[:output].to_s
|
||||
|
||||
output.html_safe
|
||||
context[:plain_text] ? output : output.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def to_document(text)
|
||||
@@ -43,25 +77,7 @@ module OpenProject::TextFormatting::Formats::Markdown
|
||||
end
|
||||
|
||||
def filters
|
||||
[
|
||||
OpenProject::TextFormatting::Filters::SettingMacrosFilter,
|
||||
OpenProject::TextFormatting::Filters::MarkdownFilter,
|
||||
OpenProject::TextFormatting::Filters::SanitizationFilter,
|
||||
OpenProject::TextFormatting::Filters::TaskListFilter,
|
||||
OpenProject::TextFormatting::Filters::TableOfContentsFilter,
|
||||
OpenProject::TextFormatting::Filters::MacroFilter,
|
||||
OpenProject::TextFormatting::Filters::MentionFilter,
|
||||
OpenProject::TextFormatting::Filters::PatternMatcherFilter,
|
||||
OpenProject::TextFormatting::Filters::SyntaxHighlightFilter,
|
||||
OpenProject::TextFormatting::Filters::AttachmentFilter,
|
||||
OpenProject::TextFormatting::Filters::AutolinkFilter,
|
||||
OpenProject::TextFormatting::Filters::AutolinkCustomProtocolsFilter,
|
||||
OpenProject::TextFormatting::Filters::RelativeLinkFilter,
|
||||
OpenProject::TextFormatting::Filters::LinkAttributeFilter,
|
||||
OpenProject::TextFormatting::Filters::ExternalLinkCaptureFilter,
|
||||
OpenProject::TextFormatting::Filters::FigureWrappedFilter,
|
||||
OpenProject::TextFormatting::Filters::BemCssFilter
|
||||
]
|
||||
context[:plain_text] ? TEXT_FILTERS : RICH_FILTERS
|
||||
end
|
||||
|
||||
def self.format
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module OpenProject::TextFormatting
|
||||
module Helpers
|
||||
# Anchor text for the static-HTML form of a WP quickinfo macro:
|
||||
# `[status ]type label: subject`. Used in channels (HTML mailers,
|
||||
# server-side previews) that can't hydrate the `<opce-*>` widget.
|
||||
module StaticMacroLabel
|
||||
def self.call(work_package, label:, detailed:)
|
||||
parts = []
|
||||
parts << work_package.status&.name if detailed
|
||||
parts << work_package.type&.name
|
||||
parts << label
|
||||
"#{parts.compact.join(' ')}: #{work_package.subject}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -78,41 +78,56 @@ module OpenProject::TextFormatting::Matchers
|
||||
# Both quickinfo and plain link need the WP record so the rendered
|
||||
# HTML can carry the record id in `data-id`. Unresolved WP →
|
||||
# literal text rather than a broken reference.
|
||||
wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(display_id)
|
||||
wp = preload_cache.fetch(display_id)
|
||||
return nil unless wp
|
||||
|
||||
if quickinfo?
|
||||
render_work_package_macro(id: wp.id, display_id: wp.display_id, detailed: detailed?)
|
||||
render_work_package_macro(work_package: wp, fallback_id: display_id, detailed: detailed?)
|
||||
else
|
||||
render_work_package_link(wp, fallback_id: display_id)
|
||||
end
|
||||
end
|
||||
|
||||
def render_for_numeric(wp_id)
|
||||
wp = OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.work_package_for(wp_id)
|
||||
wp = preload_cache.fetch(wp_id)
|
||||
|
||||
if quickinfo?
|
||||
# Prefer the resolved WP's identifiers; fall back to the matched
|
||||
# id when no preload is available (classic mode).
|
||||
record_id = wp&.id || wp_id
|
||||
display_id = wp&.display_id || wp_id
|
||||
render_work_package_macro(id: record_id, display_id:, detailed: detailed?)
|
||||
render_work_package_macro(work_package: wp, fallback_id: wp_id, detailed: detailed?)
|
||||
else
|
||||
render_work_package_link(wp, fallback_id: wp_id)
|
||||
end
|
||||
end
|
||||
|
||||
def render_work_package_macro(id:, display_id:, detailed: false)
|
||||
def render_work_package_macro(work_package:, fallback_id:, detailed: false)
|
||||
id = work_package&.id || fallback_id
|
||||
display_id = work_package&.display_id || fallback_id
|
||||
label = WorkPackage::SemanticIdentifier.format_display_id(display_id)
|
||||
|
||||
return label if text_only?(work_package)
|
||||
return render_static_work_package_macro(work_package, label, detailed:) if context[:static_html]
|
||||
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id:, display_id:, detailed: }
|
||||
end
|
||||
|
||||
# The label keeps what the author wrote (possibly a historical
|
||||
# alias) so the rendered text matches the source markdown.
|
||||
def render_static_work_package_macro(work_package, label, detailed:)
|
||||
return label unless work_package
|
||||
|
||||
link_to(OpenProject::TextFormatting::Helpers::StaticMacroLabel.call(work_package, label:, detailed:),
|
||||
work_package_path_or_url(id: work_package.display_id, only_path: context[:only_path]),
|
||||
class: "issue work_package")
|
||||
end
|
||||
|
||||
def render_work_package_link(work_package, fallback_id:)
|
||||
# Fall back to the bare `#N` shape when no WP is provided (classic mode,
|
||||
# render path bypassing `PatternMatcherFilter`) rather than running a
|
||||
# per-link query inside the renderer.
|
||||
label = work_package&.formatted_id || "##{fallback_id}"
|
||||
return label if text_only?(work_package)
|
||||
|
||||
href_id = work_package&.display_id || fallback_id
|
||||
|
||||
link_to(label,
|
||||
@@ -123,6 +138,16 @@ module OpenProject::TextFormatting::Matchers
|
||||
hover_card_url: hover_card_work_package_path(href_id)
|
||||
})
|
||||
end
|
||||
|
||||
# A nil WP means classic mode skipped the preload, or the reference
|
||||
# didn't resolve — neither case needs visibility gating.
|
||||
def text_only?(work_package)
|
||||
context[:plain_text] || (work_package && !preload_cache.visible?(work_package.id))
|
||||
end
|
||||
|
||||
def preload_cache
|
||||
OpenProject::TextFormatting::Matchers::ResourceLinksMatcher.current_cache
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,8 +69,31 @@ module OpenProject::TextFormatting
|
||||
# identifier:version:1.0.0
|
||||
# identifier:source:some/file
|
||||
class ResourceLinksMatcher < RegexMatcher
|
||||
WORK_PACKAGES_LOOKUP_KEY = :text_formatting_work_packages_lookup
|
||||
private_constant :WORK_PACKAGES_LOOKUP_KEY
|
||||
# Unscoped label resolution (`lookup`) paired with viewer-scoped
|
||||
# link gating (`visible_ids`), so the link handler renders the
|
||||
# same label for everyone and decides anchor-vs-text per viewer.
|
||||
class WorkPackagePreloadCache
|
||||
attr_reader :lookup, :visible_ids
|
||||
|
||||
def initialize(lookup:, visible_ids:)
|
||||
@lookup = lookup
|
||||
@visible_ids = visible_ids
|
||||
end
|
||||
|
||||
def fetch(identifier)
|
||||
lookup[identifier.to_s]
|
||||
end
|
||||
|
||||
def visible?(work_package_id)
|
||||
visible_ids.include?(work_package_id)
|
||||
end
|
||||
|
||||
# Frozen singleton, not a factory — callers must not mutate it.
|
||||
EMPTY = new(lookup: {}.freeze, visible_ids: Set.new.freeze).freeze
|
||||
end
|
||||
|
||||
CACHE_KEY = :text_formatting_work_package_preload_cache
|
||||
private_constant :CACHE_KEY
|
||||
|
||||
include ::OpenProject::TextFormatting::Truncation
|
||||
# used for the work package quick links
|
||||
@@ -130,33 +153,33 @@ module OpenProject::TextFormatting
|
||||
]
|
||||
end
|
||||
|
||||
# Returns the preloaded WorkPackage for the given identifier (numeric
|
||||
# or semantic), or nil if no preload is active (classic mode, no `#N`
|
||||
# references) or the WP couldn't be resolved. Lookup keys are always
|
||||
# strings — see `index_by_id_and_identifier`.
|
||||
def self.work_package_for(identifier)
|
||||
RequestStore.store.dig(WORK_PACKAGES_LOOKUP_KEY, identifier.to_s)
|
||||
def self.current_cache
|
||||
RequestStore.store[CACHE_KEY] || WorkPackagePreloadCache::EMPTY
|
||||
end
|
||||
|
||||
# Doc-level preload called by `PatternMatcherFilter`. Save/restores
|
||||
# the lookup so a nested `format_text` (e.g. custom-field formatter
|
||||
# re-entering the pipeline) doesn't clobber the outer render. Classic
|
||||
# mode skips the load — `display_id` collapses to numeric, so the
|
||||
# link handler can render from the matched id alone.
|
||||
def self.with_preloaded_resources(doc, _context)
|
||||
previous = RequestStore.store[WORK_PACKAGES_LOOKUP_KEY]
|
||||
|
||||
return yield unless Setting::WorkPackageIdentifier.semantic?
|
||||
# Save/restore so a nested `format_text` (e.g. a custom-field
|
||||
# formatter re-entering the pipeline) doesn't clobber the outer
|
||||
# render's cache.
|
||||
def self.with_preloaded_resources(doc, context)
|
||||
previous = RequestStore.store[CACHE_KEY]
|
||||
return yield unless preload_required?(context)
|
||||
|
||||
identifiers = collect_work_package_identifiers(doc)
|
||||
return yield if identifiers.empty?
|
||||
|
||||
RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = build_lookup(identifiers)
|
||||
RequestStore.store[CACHE_KEY] = build_cache(identifiers, context)
|
||||
yield
|
||||
ensure
|
||||
RequestStore.store[WORK_PACKAGES_LOOKUP_KEY] = previous
|
||||
RequestStore.store[CACHE_KEY] = previous
|
||||
end
|
||||
|
||||
# Semantic mode needs the row to map `PROJ-7` to an id; static-HTML
|
||||
# output needs `type`/`subject` to compose the quickinfo anchor.
|
||||
def self.preload_required?(context)
|
||||
Setting::WorkPackageIdentifier.semantic? || context[:static_html]
|
||||
end
|
||||
private_class_method :preload_required?
|
||||
|
||||
def self.collect_work_package_identifiers(doc)
|
||||
identifiers = Set.new
|
||||
doc.search(".//text()").each do |node|
|
||||
@@ -182,19 +205,25 @@ module OpenProject::TextFormatting
|
||||
identifier
|
||||
end
|
||||
|
||||
# 1 SELECT in the common case. A second targeted SELECT fires for
|
||||
# historical aliases — the loaded WP row carries only its current
|
||||
# identifier, so unmapped inputs must be filled in from
|
||||
# `WorkPackageSemanticAlias`. The visible-id set passes through
|
||||
# to the alias fold-in explicitly so each query enforces
|
||||
# visibility at its own boundary, leaving no implicit trust for
|
||||
# the cache to leak through.
|
||||
def self.build_lookup(identifiers)
|
||||
work_packages = WorkPackage.visible.where_display_id_in(*identifiers).select(:id, :identifier).to_a
|
||||
# Two SELECTs in the common case (unscoped fetch + visibility id
|
||||
# pluck), a third when historical aliases need resolving. Static-
|
||||
# HTML output additionally needs `:type` and `:status` to compose
|
||||
# the anchor for `##`/`###` macros.
|
||||
def self.build_cache(identifiers, context = {})
|
||||
scope = WorkPackage.where_display_id_in(*identifiers)
|
||||
scope = if context[:static_html]
|
||||
scope.includes(:type, :status)
|
||||
else
|
||||
scope.select(:id, :identifier)
|
||||
end
|
||||
work_packages = scope.to_a
|
||||
all_wp_ids = work_packages.map(&:id)
|
||||
visible_ids = WorkPackage.visible.where(id: all_wp_ids).pluck(:id).to_set
|
||||
lookup = index_by_id_and_identifier(work_packages)
|
||||
fold_in_alias_keys(lookup, identifiers, visible_wp_ids: work_packages.map(&:id))
|
||||
lookup
|
||||
fold_in_alias_keys(lookup, identifiers, all_wp_ids:)
|
||||
WorkPackagePreloadCache.new(lookup:, visible_ids:)
|
||||
end
|
||||
private_class_method :build_cache
|
||||
|
||||
# Keys are stringified at write time so callers can read with a single
|
||||
# `identifier.to_s` regardless of whether the input is a numeric id or
|
||||
@@ -207,12 +236,12 @@ module OpenProject::TextFormatting
|
||||
end
|
||||
private_class_method :index_by_id_and_identifier
|
||||
|
||||
def self.fold_in_alias_keys(lookup, identifiers, visible_wp_ids:)
|
||||
def self.fold_in_alias_keys(lookup, identifiers, all_wp_ids:)
|
||||
unmapped = identifiers.map(&:to_s) - lookup.keys
|
||||
return if unmapped.empty? || visible_wp_ids.empty?
|
||||
return if unmapped.empty? || all_wp_ids.empty?
|
||||
|
||||
WorkPackageSemanticAlias
|
||||
.where(work_package_id: visible_wp_ids, identifier: unmapped)
|
||||
.where(work_package_id: all_wp_ids, identifier: unmapped)
|
||||
.pluck(:identifier, :work_package_id)
|
||||
.each { |ident, wp_id| lookup[ident] = lookup[wp_id.to_s] }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module OpenProject
|
||||
module TextFormatting
|
||||
# Maps a high-level rendering channel (`:in_app_html`, `:external_html`,
|
||||
# `:external_text`) onto the primitive `only_path` / `static_html` /
|
||||
# `plain_text` context flags that the filter pipeline reads.
|
||||
#
|
||||
# External surfaces always need absolute URLs *and* static rendering for
|
||||
# JS-dependent components — the two flags are a coupled set. A single
|
||||
# mode value is the canonical API; the primitives stay available as
|
||||
# per-flag escape hatches for callers that need an asymmetric mix.
|
||||
module RenderMode
|
||||
DEFAULTS = {
|
||||
in_app_html: { only_path: true, static_html: false, plain_text: false }.freeze,
|
||||
external_html: { only_path: false, static_html: true, plain_text: false }.freeze,
|
||||
external_text: { only_path: false, static_html: false, plain_text: true }.freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def resolve(mode, only_path: nil, static_html: nil, plain_text: nil)
|
||||
defaults = DEFAULTS.fetch(mode) do
|
||||
raise ArgumentError, "Unknown render_mode: #{mode.inspect}. " \
|
||||
"Expected one of #{DEFAULTS.keys.inspect}."
|
||||
end
|
||||
|
||||
{
|
||||
only_path: only_path.nil? ? defaults[:only_path] : only_path,
|
||||
static_html: static_html.nil? ? defaults[:static_html] : static_html,
|
||||
plain_text: plain_text.nil? ? defaults[:plain_text] : plain_text
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe MailFormattingHelper do
|
||||
shared_let(:project) { create(:project, identifier: "macroproj") }
|
||||
shared_let(:work_package) { create(:work_package, project:, subject: "test task") }
|
||||
shared_let(:admin) { create(:admin) }
|
||||
|
||||
before { login_as(admin) }
|
||||
|
||||
describe "#format_mail_html" do
|
||||
subject(:rendered) { helper.format_mail_html("see ###{work_package.id}") }
|
||||
|
||||
it "renders the quickinfo macro as a static anchor (not the Angular custom element)" do
|
||||
expect(rendered).to include(%(class="issue work_package))
|
||||
expect(rendered).not_to include("<opce-macro-wp-quickinfo")
|
||||
end
|
||||
|
||||
it "uses an absolute URL (no relative path)" do
|
||||
expect(rendered).to match(%r{href="https?://[^/"]+/work_packages/})
|
||||
end
|
||||
end
|
||||
|
||||
describe "#format_mail_text" do
|
||||
subject(:rendered) { helper.format_mail_text("see ##{work_package.id}").strip }
|
||||
|
||||
context "in classic identifier mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "strips to plain text with the hash-prefixed numeric id" do
|
||||
expect(rendered).to eq("see ##{work_package.id}")
|
||||
expect(rendered).not_to include("<")
|
||||
end
|
||||
end
|
||||
|
||||
context "in semantic identifier mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before { work_package.allocate_and_register_semantic_id }
|
||||
|
||||
it "strips to plain text with the bare formatted identifier" do
|
||||
wp = work_package.reload
|
||||
expect(rendered).to eq("see #{wp.formatted_id}")
|
||||
expect(rendered).not_to include("<")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -171,6 +171,80 @@ RSpec.describe OpenProject::TextFormatting::Filters::MentionFilter do
|
||||
end
|
||||
end
|
||||
|
||||
context "with a mention to an inaccessible WP",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
# Label resolution is unscoped so the envelope renders the WP's
|
||||
# current `formatted_id` (e.g. `HIDDEN-1`) rather than the literal
|
||||
# envelope text the author originally typed — keeps the mention
|
||||
# path consistent with `#N` text references in the same render.
|
||||
let(:project) { create(:project, identifier: "VISIBLE") }
|
||||
let(:hidden_project) { create(:project, identifier: "HIDDEN") }
|
||||
let(:hidden_wp) { create(:work_package, project: hidden_project) }
|
||||
|
||||
before { hidden_wp.allocate_and_register_semantic_id }
|
||||
|
||||
it "renders the formatted_id as plain text with no anchor or quickinfo" do
|
||||
wp = hidden_wp.reload
|
||||
rendered = format_text(mention_tag(wp))
|
||||
|
||||
expect(rendered).to include(wp.formatted_id)
|
||||
expect(rendered).not_to match(%r{<a[^>]*>\s*#{Regexp.escape(wp.formatted_id)}\s*</a>})
|
||||
expect(rendered).not_to include(%(href="/work_packages/#{wp.display_id}"))
|
||||
expect(rendered).not_to include("opce-macro-wp-quickinfo")
|
||||
end
|
||||
|
||||
it "renders a quickinfo envelope (`##`) as plain text too" do
|
||||
wp = hidden_wp.reload
|
||||
rendered = format_text(mention_tag(wp, sep: "##"))
|
||||
|
||||
expect(rendered).to include(wp.formatted_id)
|
||||
expect(rendered).not_to include("opce-macro-wp-quickinfo")
|
||||
end
|
||||
end
|
||||
|
||||
context "in plain-text rendering mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
# `plain_text: true` must collapse mention envelopes to their current
|
||||
# `formatted_id` so the `text/plain` mailer doesn't leak `<mention>`
|
||||
# HTML or stale envelope text.
|
||||
let(:project) { create(:project, identifier: "MACROPROJ") }
|
||||
let(:work_package) { create(:work_package, project:, author:) }
|
||||
|
||||
before { work_package.allocate_and_register_semantic_id }
|
||||
|
||||
it "renders the formatted_id without an anchor or quickinfo" do
|
||||
wp = work_package.reload
|
||||
rendered = format_text(mention_tag(wp), plain_text: true)
|
||||
|
||||
expect(rendered).to include(wp.formatted_id)
|
||||
expect(rendered).not_to include("<a")
|
||||
expect(rendered).not_to include("opce-macro-wp-quickinfo")
|
||||
expect(rendered).not_to include("<mention")
|
||||
end
|
||||
end
|
||||
|
||||
context "in plain-text rendering mode (classic)",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
let(:project) { create(:project, identifier: "macroproj") }
|
||||
let(:work_package) { create(:work_package, project:, author:) }
|
||||
|
||||
it "renders the hash-prefixed numeric id without an anchor or quickinfo" do
|
||||
rendered = format_text(mention_tag(work_package), plain_text: true)
|
||||
|
||||
expect(rendered).to include("##{work_package.id}")
|
||||
expect(rendered).not_to include("<a")
|
||||
expect(rendered).not_to include("opce-macro-wp-quickinfo")
|
||||
expect(rendered).not_to include("<mention")
|
||||
end
|
||||
end
|
||||
|
||||
# No classic-mode counterpart of "inaccessible WP renders as plain text":
|
||||
# the mention filter does collapse the envelope to a bare `#N`, but the
|
||||
# downstream `PatternMatcherFilter` re-renders `#N` as an anchor — its
|
||||
# visibility gating only runs when the WP cache is preloaded (semantic
|
||||
# mode or static-HTML channels), not for classic-mode rich-HTML.
|
||||
# Channel-specific coverage lives in the static-HTML formatter spec.
|
||||
|
||||
# Semantic-shaped data-ids must not silently resolve to a WP whose id
|
||||
# matches the embedded digits.
|
||||
context "with a semantic-shaped data-id whose embedded digits collide with a real WP id",
|
||||
@@ -188,5 +262,27 @@ RSpec.describe OpenProject::TextFormatting::Filters::MentionFilter do
|
||||
expect(rendered).not_to include(%(/work_packages/#{work_package.id}))
|
||||
end
|
||||
end
|
||||
|
||||
describe "principal mention preload" do
|
||||
let(:project) { create(:project, identifier: "macroproj") }
|
||||
|
||||
def user_mention_tag(user)
|
||||
%(<mention class="mention" data-id="#{user.id}" data-type="user" data-text="@#{user.name}">@#{user.name}</mention>)
|
||||
end
|
||||
|
||||
it "loads many mentioned users with a single users SELECT keyed by id" do
|
||||
users = create_list(:user, 5, member_with_roles: { project => role })
|
||||
tags = users.map { |u| user_mention_tag(u) }.join
|
||||
|
||||
recorder = ActiveRecord::QueryRecorder.new { format_text(tags) }
|
||||
# Match SELECTs whose primary FROM is users (the column projection
|
||||
# starts with `"users"."..."`), so permission subqueries with a
|
||||
# nested `FROM "users"` don't get counted.
|
||||
batched = recorder.log.grep(/\ASELECT "users"\.[^,]+,.*FROM "users"/i)
|
||||
|
||||
expect(batched.size).to eq(1),
|
||||
"expected exactly one batched users SELECT, got #{batched.size}:\n#{batched.join("\n")}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe "Markdown static-HTML rendering" do # rubocop:disable RSpec/DescribeClass
|
||||
subject(:formatted) { render(input) }
|
||||
|
||||
def render(text)
|
||||
OpenProject::TextFormatting::Renderer.format_text(
|
||||
text,
|
||||
format: :rich,
|
||||
**context.merge(static_html: true)
|
||||
)
|
||||
end
|
||||
|
||||
let(:context) { { only_path: false } }
|
||||
|
||||
shared_let(:project) { create(:project, identifier: "demo") }
|
||||
shared_let(:type) { create(:type, name: "Task") }
|
||||
shared_let(:status) { create(:status, name: "New") }
|
||||
shared_let(:work_package) do
|
||||
create(:work_package, project:, type:, status:, subject: "Cats V Dogs")
|
||||
end
|
||||
shared_let(:admin) { create(:admin) }
|
||||
|
||||
before { login_as(admin) }
|
||||
|
||||
describe "no JS-hydrated custom elements" do
|
||||
let(:input) { "see #{'##'}#{work_package.id} for details" }
|
||||
|
||||
it "never emits <opce-macro-wp-quickinfo>" do
|
||||
expect(formatted).not_to include("opce-macro-wp-quickinfo")
|
||||
end
|
||||
end
|
||||
|
||||
describe "basic mention (#N) — no behaviour change" do
|
||||
let(:input) { "see ##{work_package.id} please" }
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before { work_package.update_columns(identifier: "DEMO-1", sequence_number: 1) }
|
||||
|
||||
it "renders the formatted_id as an anchor" do
|
||||
expect(formatted).to include(">DEMO-1<")
|
||||
expect(formatted).to include('href="')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "quickinfo macro (##N) — type + id + subject in static anchor" do
|
||||
let(:input) { "see #{'##'}#{work_package.id}" }
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before { work_package.update_columns(identifier: "DEMO-1", sequence_number: 1) }
|
||||
|
||||
it "renders type + formatted_id + subject as a single anchor" do
|
||||
expect(formatted).to match(%r{<a\b[^>]*>Task DEMO-1: Cats V Dogs</a>})
|
||||
end
|
||||
|
||||
it "links to the work package show path" do
|
||||
expect(formatted).to include(%(href="http))
|
||||
expect(formatted).to include("/work_packages/DEMO-1")
|
||||
end
|
||||
end
|
||||
|
||||
context "in classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "renders type + #N + subject as a single anchor" do
|
||||
expect(formatted).to match(%r{<a\b[^>]*>Task ##{work_package.id}: Cats V Dogs</a>})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "detailed macro (###N) — status + type + id + subject" do
|
||||
let(:input) { "see #{'###'}#{work_package.id}" }
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before { work_package.update_columns(identifier: "DEMO-1", sequence_number: 1) }
|
||||
|
||||
it "renders status + type + formatted_id + subject as a single anchor" do
|
||||
expect(formatted).to match(%r{<a\b[^>]*>New Task DEMO-1: Cats V Dogs</a>})
|
||||
end
|
||||
end
|
||||
|
||||
context "in classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "renders status + type + #N + subject as a single anchor" do
|
||||
expect(formatted).to match(%r{<a\b[^>]*>New Task ##{work_package.id}: Cats V Dogs</a>})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "inaccessible work package" do
|
||||
shared_let(:other_project) { create(:project, identifier: "secret") }
|
||||
shared_let(:reader_role) { create(:project_role, permissions: %i[view_work_packages]) }
|
||||
shared_let(:reader) { create(:user, member_with_roles: { project => reader_role }) }
|
||||
shared_let(:hidden_wp) do
|
||||
create(:work_package, project: other_project, type:, status:, subject: "Hidden")
|
||||
end
|
||||
|
||||
before { login_as(reader) }
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before { hidden_wp.update_columns(identifier: "SECRET-1", sequence_number: 1) }
|
||||
|
||||
it "renders the bare identifier label without an anchor for ##N" do
|
||||
rendered = render("see #{'##'}#{hidden_wp.id}")
|
||||
expect(rendered).to include("SECRET-1")
|
||||
expect(rendered).not_to match(%r{<a[^>]*>[^<]*SECRET-1})
|
||||
expect(rendered).not_to include("Hidden")
|
||||
end
|
||||
|
||||
it "renders the bare identifier label without an anchor for ###N" do
|
||||
rendered = render("see #{'###'}#{hidden_wp.id}")
|
||||
expect(rendered).to include("SECRET-1")
|
||||
expect(rendered).not_to match(%r{<a[^>]*>[^<]*SECRET-1})
|
||||
end
|
||||
end
|
||||
|
||||
context "in classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "renders the bare #N label without an anchor for ##N" do
|
||||
rendered = render("see #{'##'}#{hidden_wp.id}")
|
||||
expect(rendered).to include("##{hidden_wp.id}")
|
||||
expect(rendered).not_to match(%r{<a[^>]*>[^<]*##{hidden_wp.id}})
|
||||
expect(rendered).not_to include("Hidden")
|
||||
end
|
||||
|
||||
it "renders the bare #N label without an anchor for ###N" do
|
||||
rendered = render("see #{'###'}#{hidden_wp.id}")
|
||||
expect(rendered).to include("##{hidden_wp.id}")
|
||||
expect(rendered).not_to match(%r{<a[^>]*>[^<]*##{hidden_wp.id}})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "work-package mention envelope" do
|
||||
let(:mention_attrs) do
|
||||
%(class="mention" data-id="#{work_package.id}" data-type="work_package" data-display-id="DEMO-1")
|
||||
end
|
||||
let(:input) { %(check <mention #{mention_attrs}>##DEMO-1</mention>) }
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before { work_package.update_columns(identifier: "DEMO-1", sequence_number: 1) }
|
||||
|
||||
it "renders ## quickinfo envelopes as a static anchor with type + id + subject" do
|
||||
expect(formatted).to match(%r{<a\b[^>]*>Task DEMO-1: Cats V Dogs</a>})
|
||||
expect(formatted).not_to include("opce-macro-wp-quickinfo")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Public document exports and other non-authenticated rendering paths
|
||||
# invoke the static-HTML pipeline with `User.current == User.anonymous`.
|
||||
# In that context every non-public WP is invisible, so any mention must
|
||||
# collapse to its current `formatted_id` as plain text — no anchor, no
|
||||
# subject leak.
|
||||
describe "anonymous current_user" do
|
||||
shared_let(:private_project) { create(:project, identifier: "private", public: false) }
|
||||
shared_let(:private_wp) do
|
||||
create(:work_package, project: private_project, type:, status:, subject: "Top Secret")
|
||||
end
|
||||
|
||||
around do |example|
|
||||
User.execute_as(User.anonymous) { example.run }
|
||||
end
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before { private_wp.update_columns(identifier: "PRIVATE-1", sequence_number: 1) }
|
||||
|
||||
it "does not raise and renders the identifier text" do
|
||||
expect { render("see #{'##'}#{private_wp.id}") }
|
||||
.not_to raise_error
|
||||
|
||||
rendered = render("see #{'##'}#{private_wp.id}")
|
||||
expect(rendered).to include("PRIVATE-1")
|
||||
end
|
||||
end
|
||||
|
||||
context "in classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "does not raise and renders the #N text" do
|
||||
expect { render("see #{'##'}#{private_wp.id}") }
|
||||
.not_to raise_error
|
||||
|
||||
rendered = render("see #{'##'}#{private_wp.id}")
|
||||
expect(rendered).to include("##{private_wp.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,153 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe "Markdown plain-text rendering" do # rubocop:disable RSpec/DescribeClass
|
||||
subject(:formatted) { render(input).strip }
|
||||
|
||||
def render(text)
|
||||
OpenProject::TextFormatting::Renderer.format_text(text, plain_text: true)
|
||||
end
|
||||
|
||||
describe "plain markdown" do
|
||||
let(:input) { "Hello *world*" }
|
||||
|
||||
it "renders text without HTML tags" do
|
||||
expect(formatted).to eq("Hello world")
|
||||
end
|
||||
end
|
||||
|
||||
describe "with an inline work-package reference" do
|
||||
shared_let(:project) { create(:project, identifier: "demo") }
|
||||
shared_let(:work_package) { create(:work_package, project:, subject: "task") }
|
||||
shared_let(:admin) { create(:admin) }
|
||||
|
||||
let(:input) { "see ##{work_package.id} please" }
|
||||
|
||||
before { login_as(admin) }
|
||||
|
||||
context "in classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "renders the hash-prefixed numeric id" do
|
||||
expect(formatted).to eq("see ##{work_package.id} please")
|
||||
end
|
||||
end
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before do
|
||||
work_package.update_columns(identifier: "DEMO-1", sequence_number: 1)
|
||||
end
|
||||
|
||||
it "renders the bare semantic identifier" do
|
||||
expect(formatted).to eq("see DEMO-1 please")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "with a quickinfo macro reference" do
|
||||
shared_let(:project) { create(:project, identifier: "demo") }
|
||||
shared_let(:work_package) { create(:work_package, project:, subject: "task") }
|
||||
shared_let(:admin) { create(:admin) }
|
||||
|
||||
before { login_as(admin) }
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before do
|
||||
work_package.update_columns(identifier: "DEMO-1", sequence_number: 1)
|
||||
end
|
||||
|
||||
it "renders ##N as bare semantic identifier" do
|
||||
expect(render("see #{'##'}#{work_package.id} please").strip).to eq("see DEMO-1 please")
|
||||
end
|
||||
|
||||
it "renders ###N as bare semantic identifier" do
|
||||
expect(render("see #{'###'}#{work_package.id} please").strip).to eq("see DEMO-1 please")
|
||||
end
|
||||
end
|
||||
|
||||
context "in classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "renders ##N as the hash-prefixed numeric id" do
|
||||
expect(render("see #{'##'}#{work_package.id} please").strip).to eq("see ##{work_package.id} please")
|
||||
end
|
||||
|
||||
it "renders ###N as the hash-prefixed numeric id" do
|
||||
expect(render("see #{'###'}#{work_package.id} please").strip).to eq("see ##{work_package.id} please")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "with a work-package mention envelope" do
|
||||
shared_let(:project) { create(:project, identifier: "demo") }
|
||||
shared_let(:work_package) { create(:work_package, project:, subject: "task") }
|
||||
shared_let(:admin) { create(:admin) }
|
||||
|
||||
let(:mention_attrs) do
|
||||
%(class="mention" data-id="#{work_package.id}" data-type="work_package" data-display-id="DEMO-1")
|
||||
end
|
||||
let(:input) { %(check <mention #{mention_attrs}>#DEMO-1</mention>) }
|
||||
|
||||
before { login_as(admin) }
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before do
|
||||
work_package.update_columns(identifier: "DEMO-1", sequence_number: 1)
|
||||
end
|
||||
|
||||
it "unwraps to the bare semantic identifier" do
|
||||
expect(formatted).to eq("check DEMO-1")
|
||||
end
|
||||
end
|
||||
|
||||
context "in classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "unwraps to the hash-prefixed numeric id" do
|
||||
expect(formatted).to eq("check ##{work_package.id}")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a ##-shaped mention text in semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
let(:input) { %(check <mention #{mention_attrs}>##DEMO-1</mention>) }
|
||||
|
||||
before do
|
||||
work_package.update_columns(identifier: "DEMO-1", sequence_number: 1)
|
||||
end
|
||||
|
||||
it "unwraps to the bare semantic identifier" do
|
||||
expect(formatted).to eq("check DEMO-1")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+38
-37
@@ -94,17 +94,17 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
inner_doc = Nokogiri::HTML.fragment("##{inner.id}")
|
||||
|
||||
matcher.with_preloaded_resources(outer_doc, {}) do
|
||||
expect(matcher.work_package_for(outer.id)).to eq(outer)
|
||||
expect(matcher.current_cache.fetch(outer.id)).to eq(outer)
|
||||
|
||||
matcher.with_preloaded_resources(inner_doc, {}) do
|
||||
expect(matcher.work_package_for(inner.id)).to eq(inner)
|
||||
expect(matcher.current_cache.fetch(inner.id)).to eq(inner)
|
||||
end
|
||||
|
||||
expect(matcher.work_package_for(outer.id))
|
||||
expect(matcher.current_cache.fetch(outer.id))
|
||||
.to eq(outer), "outer lookup should be restored after nested call"
|
||||
end
|
||||
|
||||
expect(matcher.work_package_for(outer.id)).to be_nil
|
||||
expect(matcher.current_cache.fetch(outer.id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -134,15 +134,17 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
include_context "with author signed in"
|
||||
let(:project) { create(:project, identifier: "NPLUSONE") }
|
||||
|
||||
it "loads referenced work packages with a single SELECT regardless of count" do
|
||||
it "loads referenced work packages with a fixed two-SELECT preload regardless of count" do
|
||||
wps = create_list(:work_package, 5, project:, author:)
|
||||
ids_text = wps.map { |wp| "##{wp.id}" }.join(" ")
|
||||
|
||||
recorder = ActiveRecord::QueryRecorder.new { format_text(ids_text) }
|
||||
wp_selects = recorder.log.grep(/FROM "work_packages"/i)
|
||||
|
||||
expect(wp_selects.size).to eq(1),
|
||||
"expected exactly one work_packages SELECT, got #{wp_selects.size}:\n#{wp_selects.join("\n")}"
|
||||
# One unscoped fetch by identifier (label resolution) plus one
|
||||
# visibility-scoped pluck on the resulting ids (link gating).
|
||||
expect(wp_selects.size).to eq(2),
|
||||
"expected exactly two work_packages SELECTs, got #{wp_selects.size}:\n#{wp_selects.join("\n")}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -199,7 +201,7 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
end
|
||||
|
||||
context "with mixed numeric and semantic references in one render" do
|
||||
it "resolves both with a single work_packages SELECT" do
|
||||
it "resolves both with the fixed two-SELECT preload" do
|
||||
wps = create_list(:work_package, 2, project:, author:)
|
||||
wps.each(&:allocate_and_register_semantic_id)
|
||||
loaded = wps.map(&:reload)
|
||||
@@ -209,8 +211,8 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
recorder = ActiveRecord::QueryRecorder.new { rendered = format_text(text) }
|
||||
wp_selects = recorder.log.grep(/FROM "work_packages"/i)
|
||||
|
||||
expect(wp_selects.size).to eq(1),
|
||||
"expected exactly one work_packages SELECT, got #{wp_selects.size}:\n#{wp_selects.join("\n")}"
|
||||
expect(wp_selects.size).to eq(2),
|
||||
"expected exactly two work_packages SELECTs, got #{wp_selects.size}:\n#{wp_selects.join("\n")}"
|
||||
|
||||
# Both render with the user-facing display_id, regardless of which
|
||||
# form the user typed.
|
||||
@@ -220,7 +222,7 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
end
|
||||
|
||||
context "with a historical alias reference" do
|
||||
it "resolves via the alias table with two round-trips total" do
|
||||
it "resolves via the alias table with bounded round-trips" do
|
||||
wp = work_package.reload
|
||||
# Simulate a project rename: the WP keeps its current MACROPROJ-N
|
||||
# identifier on the row, but a historical OLD-prefix alias row
|
||||
@@ -231,17 +233,17 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
rendered = nil
|
||||
recorder = ActiveRecord::QueryRecorder.new { rendered = format_text("see #OLDPROJ-1") }
|
||||
|
||||
# Two targeted round-trips: (1) `where_display_id_in` runs a
|
||||
# single WP SELECT whose WHERE clause includes an EXISTS
|
||||
# subquery against the alias table; (2) a sidecar alias pluck
|
||||
# maps the historical input string back to its WP for the
|
||||
# cache. Scoped greps ignore incidental Setting/permission
|
||||
# queries — a second match on either grep would indicate an
|
||||
# Bounded round-trips: (1) `where_display_id_in` runs an unscoped
|
||||
# WP SELECT whose WHERE includes an EXISTS subquery against the
|
||||
# alias table, (2) a visibility-scoped id pluck for link gating,
|
||||
# (3) a sidecar alias pluck maps the historical input string back
|
||||
# to its WP for the cache. Scoped greps ignore incidental
|
||||
# Setting/permission queries — additional matches indicate an
|
||||
# N+1 regression.
|
||||
wp_selects = recorder.log.grep(/FROM "work_packages"/)
|
||||
alias_selects = recorder.log.grep(/FROM "work_package_semantic_aliases"/)
|
||||
.grep_v(/FROM "work_packages"/)
|
||||
expect(wp_selects.size).to eq(1)
|
||||
expect(wp_selects.size).to eq(2)
|
||||
expect(alias_selects.size).to eq(1)
|
||||
|
||||
# Renders against the WP's CURRENT display_id, not the historical
|
||||
@@ -298,11 +300,11 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
|
||||
describe "visibility scoping",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
# The lookup cache must scope through `WorkPackage.visible` —
|
||||
# anything it surfaces ends up in the rendered link, so an
|
||||
# unscoped cache would let any user read back the project
|
||||
# identifier of a WP just by guessing its primary key, semantic
|
||||
# identifier, or historical alias.
|
||||
# Label resolution is unscoped so notification recipients see the same
|
||||
# identifier shape as authors, but anchors are still gated by
|
||||
# `WorkPackage.visible` — the link handler emits a plain-text label
|
||||
# for inaccessible WPs rather than a navigable URL or hover-card
|
||||
# endpoint.
|
||||
include_context "with author signed in"
|
||||
|
||||
let(:project) { create(:project, identifier: "VISIBLE") }
|
||||
@@ -316,46 +318,44 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
end
|
||||
|
||||
context "with a semantic-shaped ref to an inaccessible work package" do
|
||||
it "renders literal text and never surfaces the WP's display id" do
|
||||
it "renders the formatted_id as plain text with no anchor or quickinfo" do
|
||||
wp = hidden_wp.reload
|
||||
rendered = format_text("see ##{wp.display_id} here")
|
||||
|
||||
expect(rendered).to include("##{wp.display_id}")
|
||||
expect(rendered).to include(wp.formatted_id)
|
||||
expect(rendered).not_to match(%r{<a[^>]*>\s*#{Regexp.escape(wp.formatted_id)}\s*</a>})
|
||||
expect(rendered).not_to include(%(href="/work_packages/#{wp.display_id}"))
|
||||
expect(rendered).not_to include("opce-macro-wp-quickinfo")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a numeric ref to an inaccessible work package in semantic mode" do
|
||||
it "renders the numeric label and href without upgrading to the semantic identifier" do
|
||||
it "upgrades the label to the formatted_id but does not render an anchor" do
|
||||
wp = hidden_wp.reload
|
||||
rendered = format_text("see ##{wp.id} here")
|
||||
|
||||
# The link still renders — `#42` was already in the user's input —
|
||||
# but the upgrade to the WP's `formatted_id` / `display_id` (which
|
||||
# would leak the project identifier) does not happen.
|
||||
expect(rendered).to include(%(href="/work_packages/#{wp.id}"))
|
||||
expect(rendered).to include(">##{wp.id}<")
|
||||
expect(rendered).to include(wp.formatted_id)
|
||||
expect(rendered).not_to match(%r{<a[^>]*>\s*#{Regexp.escape(wp.formatted_id)}\s*</a>})
|
||||
expect(rendered).not_to include(%(href="/work_packages/#{wp.id}"))
|
||||
expect(rendered).not_to include(%(href="/work_packages/#{wp.display_id}"))
|
||||
expect(rendered).not_to include(">#{wp.formatted_id}<")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a historical alias for an inaccessible work package" do
|
||||
it "renders literal text and does not resolve via the alias table" do
|
||||
it "resolves the alias and renders the current formatted_id as plain text" do
|
||||
wp = hidden_wp.reload
|
||||
WorkPackageSemanticAlias.create!(work_package_id: wp.id, identifier: "OLDHIDDEN-1")
|
||||
|
||||
rendered = format_text("see #OLDHIDDEN-1 here")
|
||||
|
||||
expect(rendered).to include("#OLDHIDDEN-1")
|
||||
expect(rendered).to include(wp.formatted_id)
|
||||
expect(rendered).not_to match(%r{<a[^>]*>\s*#{Regexp.escape(wp.formatted_id)}\s*</a>})
|
||||
expect(rendered).not_to include(%(href="/work_packages/#{wp.display_id}"))
|
||||
expect(rendered).not_to include(">#{wp.formatted_id}<")
|
||||
end
|
||||
end
|
||||
|
||||
context "with visible and invisible refs mixed in one input" do
|
||||
it "renders the visible ref normally and falls back to literal text for the invisible one" do
|
||||
it "renders the visible ref as an anchor and the invisible ref as plain-text label" do
|
||||
visible = visible_wp.reload
|
||||
hidden = hidden_wp.reload
|
||||
rendered = format_text("see ##{visible.display_id} and ##{hidden.display_id}")
|
||||
@@ -364,7 +364,8 @@ RSpec.describe OpenProject::TextFormatting::Matchers::LinkHandlers::WorkPackages
|
||||
expect(rendered).to include(">#{visible.formatted_id}<")
|
||||
|
||||
expect(rendered).not_to include(%(href="/work_packages/#{hidden.display_id}"))
|
||||
expect(rendered).to include("##{hidden.display_id}")
|
||||
expect(rendered).to include(hidden.formatted_id)
|
||||
expect(rendered).not_to match(%r{<a[^>]*>\s*#{Regexp.escape(hidden.formatted_id)}\s*</a>})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe OpenProject::TextFormatting::RenderMode do
|
||||
describe ".resolve" do
|
||||
context "with :in_app_html" do
|
||||
it "produces the in-app default trio" do
|
||||
expect(described_class.resolve(:in_app_html)).to eq(
|
||||
only_path: true,
|
||||
static_html: false,
|
||||
plain_text: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "with :external_html" do
|
||||
it "produces absolute URLs and static-HTML rendering" do
|
||||
expect(described_class.resolve(:external_html)).to eq(
|
||||
only_path: false,
|
||||
static_html: true,
|
||||
plain_text: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "with :external_text" do
|
||||
it "produces absolute URLs and plain-text output" do
|
||||
expect(described_class.resolve(:external_text)).to eq(
|
||||
only_path: false,
|
||||
static_html: false,
|
||||
plain_text: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an explicit primitive flag" do
|
||||
it "lets only_path override while keeping the rest of the mode's defaults" do
|
||||
expect(described_class.resolve(:external_html, only_path: true)).to eq(
|
||||
only_path: true,
|
||||
static_html: true,
|
||||
plain_text: false
|
||||
)
|
||||
end
|
||||
|
||||
it "lets an explicit false override the mode's true default" do
|
||||
expect(described_class.resolve(:external_html, static_html: false)).to eq(
|
||||
only_path: false,
|
||||
static_html: false,
|
||||
plain_text: false
|
||||
)
|
||||
end
|
||||
|
||||
it "ignores a nil override (treats it as 'not passed')" do
|
||||
expect(described_class.resolve(:external_html, plain_text: nil)).to eq(
|
||||
only_path: false,
|
||||
static_html: true,
|
||||
plain_text: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an unknown mode" do
|
||||
it "raises ArgumentError naming the bad value" do
|
||||
expect { described_class.resolve(:nonsense) }
|
||||
.to raise_error(ArgumentError, /render_mode.*nonsense/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -129,6 +129,42 @@ RSpec.describe WorkPackageMailer do
|
||||
expect(mail["X-OpenProject-WorkPackage-Assignee"].value)
|
||||
.to eql work_package.assigned_to.login
|
||||
end
|
||||
|
||||
describe "rendering a journal note containing a WP reference" do
|
||||
shared_let(:persisted_project) { create(:project, identifier: "demo") }
|
||||
shared_let(:persisted_recipient) { create(:admin) }
|
||||
shared_let(:referenced_wp) { create(:work_package, project: persisted_project, subject: "child") }
|
||||
shared_let(:parent_wp) { create(:work_package, project: persisted_project, subject: "parent") }
|
||||
|
||||
let(:persisted_journal) do
|
||||
create(:work_package_journal,
|
||||
journable: parent_wp,
|
||||
user: persisted_recipient,
|
||||
version: parent_wp.journals.maximum(:version).to_i + 1,
|
||||
notes: "see ##{referenced_wp.id}")
|
||||
end
|
||||
let(:mail) { described_class.mentioned(persisted_recipient, persisted_journal) }
|
||||
|
||||
context "with classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "renders the hash-prefixed numeric id in the text body" do
|
||||
expect(mail.text_part.body.to_s).to include("##{referenced_wp.id}")
|
||||
end
|
||||
end
|
||||
|
||||
context "with semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before do
|
||||
referenced_wp.update_columns(identifier: "DEMO-1", sequence_number: 1)
|
||||
end
|
||||
|
||||
it "renders the bare semantic identifier in the text body" do
|
||||
body = mail.text_part.body.to_s
|
||||
expect(body).to include("DEMO-1")
|
||||
expect(body).not_to match(/##{referenced_wp.id}\b/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#watcher_changed" do
|
||||
@@ -209,5 +245,135 @@ RSpec.describe WorkPackageMailer do
|
||||
.to eql "op.work_package-#{work_package.id}@example.net"
|
||||
end
|
||||
end
|
||||
|
||||
describe "rendering the latest comment containing a WP reference" do
|
||||
shared_let(:persisted_project) { create(:project, identifier: "demo") }
|
||||
shared_let(:persisted_recipient) { create(:admin) }
|
||||
shared_let(:referenced_wp) { create(:work_package, project: persisted_project, subject: "child") }
|
||||
shared_let(:parent_wp) { create(:work_package, project: persisted_project, subject: "parent") }
|
||||
|
||||
let(:mail) do
|
||||
create(:work_package_journal,
|
||||
journable: parent_wp,
|
||||
user: persisted_recipient,
|
||||
version: parent_wp.journals.maximum(:version).to_i + 1,
|
||||
notes: "Updated automatically by changing values within child work package ##{referenced_wp.id}")
|
||||
described_class.watcher_changed(parent_wp, persisted_recipient, persisted_recipient, "added")
|
||||
end
|
||||
|
||||
context "with classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "renders the hash-prefixed numeric id in the text body" do
|
||||
expect(mail.text_part.body.to_s).to include("##{referenced_wp.id}")
|
||||
end
|
||||
end
|
||||
|
||||
context "with semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before do
|
||||
referenced_wp.update_columns(identifier: "DEMO-1", sequence_number: 1)
|
||||
end
|
||||
|
||||
it "renders the bare semantic identifier in the text body" do
|
||||
body = mail.text_part.body.to_s
|
||||
expect(body).to include("DEMO-1")
|
||||
expect(body).not_to match(/##{referenced_wp.id}\b/)
|
||||
end
|
||||
|
||||
it "renders the bare semantic identifier in the html body" do
|
||||
body = mail.html_part.body.to_s
|
||||
expect(body).to include("DEMO-1")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "rendering a cross-project WP reference to a recipient without visibility",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
shared_let(:parent_project) { create(:project, identifier: "parent-proj") }
|
||||
shared_let(:child_project) { create(:project, identifier: "child-proj") }
|
||||
shared_let(:parent_wp) { create(:work_package, project: parent_project, subject: "parent") }
|
||||
shared_let(:child_wp) { create(:work_package, project: child_project, subject: "child") }
|
||||
shared_let(:reader_role) { create(:project_role, permissions: %i[view_work_packages]) }
|
||||
shared_let(:reader) { create(:user, member_with_roles: { parent_project => reader_role }) }
|
||||
|
||||
let(:mail) do
|
||||
child_wp.update_columns(identifier: "CHILDPROJ-1", sequence_number: 1)
|
||||
create(:work_package_journal,
|
||||
journable: parent_wp,
|
||||
user: reader,
|
||||
version: parent_wp.journals.maximum(:version).to_i + 1,
|
||||
notes: "Updated automatically by changing values within child work package ##{child_wp.id}")
|
||||
described_class.watcher_changed(parent_wp, reader, reader, "added")
|
||||
end
|
||||
|
||||
it "renders the semantic identifier as plain text in the text body" do
|
||||
body = mail.text_part.body.to_s
|
||||
expect(body).to include("CHILDPROJ-1")
|
||||
expect(body).not_to match(/##{child_wp.id}\b/)
|
||||
end
|
||||
|
||||
it "renders the semantic identifier without an anchor in the html body" do
|
||||
body = mail.html_part.body.to_s
|
||||
expect(body).to include("CHILDPROJ-1")
|
||||
expect(body).not_to include(%(href="/work_packages/#{child_wp.id}"))
|
||||
expect(body).not_to include(%(href="/work_packages/CHILDPROJ-1"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "rendering a quickinfo/detailed macro in the latest comment" do
|
||||
shared_let(:persisted_project) { create(:project, identifier: "demo") }
|
||||
shared_let(:persisted_recipient) { create(:admin) }
|
||||
shared_let(:macro_type) { create(:type, name: "Task") }
|
||||
shared_let(:macro_status) { create(:status, name: "New") }
|
||||
shared_let(:referenced_wp) do
|
||||
create(:work_package,
|
||||
project: persisted_project,
|
||||
type: macro_type,
|
||||
status: macro_status,
|
||||
subject: "Cats V Dogs")
|
||||
end
|
||||
shared_let(:parent_wp) { create(:work_package, project: persisted_project, subject: "parent") }
|
||||
|
||||
let(:mail) do
|
||||
create(:work_package_journal,
|
||||
journable: parent_wp,
|
||||
user: persisted_recipient,
|
||||
version: parent_wp.journals.maximum(:version).to_i + 1,
|
||||
notes: "ref ##{referenced_wp.id} ##{'#'}#{referenced_wp.id} ###{'#'}#{referenced_wp.id}")
|
||||
described_class.watcher_changed(parent_wp, persisted_recipient, persisted_recipient, "added")
|
||||
end
|
||||
|
||||
context "with semantic mode",
|
||||
with_settings: { work_packages_identifier: "semantic" } do
|
||||
before { referenced_wp.update_columns(identifier: "DEMO-1", sequence_number: 1) }
|
||||
|
||||
it "renders ## quickinfo as a static anchor with type + id + subject" do
|
||||
body = mail.html_part.body.to_s
|
||||
expect(body).to match(%r{<a\b[^>]*>Task DEMO-1: Cats V Dogs</a>})
|
||||
end
|
||||
|
||||
it "renders ### detailed as a static anchor with status + type + id + subject" do
|
||||
body = mail.html_part.body.to_s
|
||||
expect(body).to match(%r{<a\b[^>]*>New Task DEMO-1: Cats V Dogs</a>})
|
||||
end
|
||||
|
||||
it "never leaks <opce-macro-wp-quickinfo> into the html body" do
|
||||
expect(mail.html_part.body.to_s).not_to include("opce-macro-wp-quickinfo")
|
||||
end
|
||||
end
|
||||
|
||||
context "with classic mode",
|
||||
with_settings: { work_packages_identifier: "classic" } do
|
||||
it "renders ## quickinfo as a static anchor with type + #N + subject" do
|
||||
body = mail.html_part.body.to_s
|
||||
expect(body).to match(%r{<a\b[^>]*>Task ##{referenced_wp.id}: Cats V Dogs</a>})
|
||||
end
|
||||
|
||||
it "renders ### detailed as a static anchor with status + type + #N + subject" do
|
||||
body = mail.html_part.body.to_s
|
||||
expect(body).to match(%r{<a\b[^>]*>New Task ##{referenced_wp.id}: Cats V Dogs</a>})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -606,6 +606,20 @@ RSpec.describe WorkPackage::SemanticIdentifier do
|
||||
end
|
||||
end
|
||||
|
||||
describe ".format_display_id" do
|
||||
it "returns the semantic identifier unchanged when it carries letters" do
|
||||
expect(described_class.format_display_id("MYPROJ-1")).to eq("MYPROJ-1")
|
||||
end
|
||||
|
||||
it "hash-prefixes a numeric integer" do
|
||||
expect(described_class.format_display_id(42)).to eq("#42")
|
||||
end
|
||||
|
||||
it "hash-prefixes a numeric string" do
|
||||
expect(described_class.format_display_id("42")).to eq("#42")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#to_param" do
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
|
||||
@@ -1385,4 +1385,45 @@ RSpec.describe WorkPackages::UpdateAncestorsService,
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "auto-generated journal note when a child triggers an ancestor recompute",
|
||||
with_settings: { work_package_done_ratio: "status" } do
|
||||
shared_let_work_packages(<<~TABLE)
|
||||
hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete
|
||||
parent | Open | 10h | 15h | 10h | 15h | 0% | 0%
|
||||
child | Open | 5h | | 5h | | 0% |
|
||||
TABLE
|
||||
|
||||
# The journal note always stores the primary-key reference (`#42`).
|
||||
# Render-time resolution in the formatter pipeline turns it into
|
||||
# `#PROJ-7` in semantic mode and `#42` in classic mode, so the stored
|
||||
# text survives project-identifier renames.
|
||||
context "in classic mode",
|
||||
with_settings: { work_package_done_ratio: "status", work_packages_identifier: "classic" } do
|
||||
it "writes the child's hash-prefixed primary key into the parent's journal note" do
|
||||
set_attributes_on(child, status: closed_status)
|
||||
call_update_ancestors_service(child)
|
||||
|
||||
note = parent.reload.journals.last.notes
|
||||
expect(note).to include("##{child.id}")
|
||||
end
|
||||
end
|
||||
|
||||
context "in semantic mode",
|
||||
with_settings: { work_package_done_ratio: "status", work_packages_identifier: "semantic" } do
|
||||
before do
|
||||
child.allocate_and_register_semantic_id
|
||||
end
|
||||
|
||||
it "writes the child's hash-prefixed primary key, not its semantic identifier" do
|
||||
set_attributes_on(child, status: closed_status)
|
||||
call_update_ancestors_service(child)
|
||||
|
||||
wp = child.reload
|
||||
note = parent.reload.journals.last.notes
|
||||
expect(note).to include("##{wp.id}")
|
||||
expect(note).not_to include(wp.identifier)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user