mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Render WP quickinfo macros as static HTML in mailer notes
The `##N` and `###N` work-package macros emit JS-hydrated `<opce-macro-wp-quickinfo>` custom elements, which mail clients collapse to empty bullets. Introduce a `:markdown_as_static_html` format that shares the rich filter chain but signals `context[:as_static_html]` so the matcher and `MentionFilter` emit a server-rendered anchor — formatted_id, type name, subject, and (for `###`) status name — closely mirroring the in-app widget once flattened. Mailer HTML templates (`mentioned`, `watcher_changed`, `_work_package_details`) opt into the new format. Invisible WPs still render as plain-text labels, matching the cross-project visibility policy. `ResourceLinksMatcher.build_cache` and `MentionFilter#preload_work_package_mentions` eager-load `:type` and `:status` only when `:as_static_html` is set, leaving the default web path's two-SELECT shape untouched. Classic-mode preload now also runs under `:as_static_html` so the link handler can resolve type/subject for `##`/`###`. Renames the internal flag `context[:plain_text]` to `context[:as_text]` to restore symmetry with the user-facing `:markdown_as_text` format.
This commit is contained in:
@@ -47,4 +47,4 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%= format_text(work_package, :description, only_path: false) %>
|
||||
<%= format_text(work_package, :description, format: :markdown_as_static_html, only_path: false) %>
|
||||
|
||||
@@ -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_text @journal.notes, format: :markdown_as_static_html, only_path: false %>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -32,6 +32,7 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<p>
|
||||
<%= format_text(
|
||||
t(:text_latest_note, note: last_work_package_note(@work_package)),
|
||||
format: :markdown_as_static_html,
|
||||
only_path: false,
|
||||
object: @work_package,
|
||||
project: @work_package.project
|
||||
|
||||
@@ -57,8 +57,18 @@ module OpenProject::TextFormatting
|
||||
# and avoids the per-mention query the old `.visible.find_by` did.
|
||||
def preload_work_package_mentions
|
||||
ids = mention_work_package_ids
|
||||
@mentioned_work_packages = ids.empty? ? {} : WorkPackage.where(id: ids).index_by(&:id)
|
||||
@visible_mentioned_ids = ids.empty? ? Set.new : WorkPackage.visible.where(id: ids).pluck(:id).to_set
|
||||
if ids.empty?
|
||||
@mentioned_work_packages = {}
|
||||
@visible_mentioned_ids = Set.new
|
||||
return
|
||||
end
|
||||
|
||||
scope = WorkPackage.where(id: ids)
|
||||
# Static-HTML channels need `type` and `status` to render
|
||||
# quickinfo envelopes as anchors instead of `<opce-*>` widgets.
|
||||
scope = scope.includes(:type, :status) if context[:as_static_html]
|
||||
@mentioned_work_packages = scope.index_by(&:id)
|
||||
@visible_mentioned_ids = WorkPackage.visible.where(id: ids).pluck(:id).to_set
|
||||
end
|
||||
|
||||
def mention_work_package_ids
|
||||
@@ -109,10 +119,12 @@ module OpenProject::TextFormatting
|
||||
# latter would resolve to a hover-card endpoint the recipient
|
||||
# can't reach.
|
||||
def text_only?(work_package)
|
||||
context[:plain_text] || @visible_mentioned_ids.exclude?(work_package.id)
|
||||
context[:as_text] || @visible_mentioned_ids.exclude?(work_package.id)
|
||||
end
|
||||
|
||||
def work_package_quickinfo(work_package, detailed:)
|
||||
return work_package_static_macro(work_package, detailed:) if context[:as_static_html]
|
||||
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id: work_package.id,
|
||||
@@ -120,6 +132,21 @@ module OpenProject::TextFormatting
|
||||
detailed: }
|
||||
end
|
||||
|
||||
# Static fallback shared with the PatternMatcherFilter's `##`/`###`
|
||||
# path so envelope-driven and text-driven references render the same
|
||||
# shape in channels that cannot hydrate the custom element.
|
||||
def work_package_static_macro(work_package, detailed:)
|
||||
parts = []
|
||||
parts << work_package.status&.name if detailed
|
||||
parts << work_package.type&.name
|
||||
parts << work_package.formatted_id
|
||||
link_text = "#{parts.compact.join(' ')}: #{work_package.subject}"
|
||||
|
||||
link_to(link_text,
|
||||
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,
|
||||
|
||||
@@ -32,7 +32,7 @@ module OpenProject::TextFormatting
|
||||
module Filters
|
||||
# Final stage of the plain-text pipeline. Earlier filters resolve
|
||||
# mentions and macros to their text-mode shapes (driven by
|
||||
# `context[:plain_text]`); this stage collapses any remaining markup
|
||||
# `context[:as_text]`); this stage collapses any remaining markup
|
||||
# so the pipeline output is suitable for `text/plain` bodies.
|
||||
class PlainTextOutputFilter < HTML::Pipeline::Filter
|
||||
def call
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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::Formats
|
||||
module Markdown
|
||||
# Static-HTML sibling of `Markdown::Formatter`. Shares the same filter
|
||||
# chain so identifier resolution, mention handling, and link rendering
|
||||
# stay consistent, but signals `context[:as_static_html]` so matchers
|
||||
# and filters emit server-rendered anchors in place of JS-hydrated
|
||||
# custom elements. Intended for channels that cannot run JS — HTML
|
||||
# mailers, server-side previews, archival exports — where dynamic
|
||||
# widgets would collapse to empty placeholders.
|
||||
class StaticHtmlFormatter < Formatter
|
||||
def initialize(context)
|
||||
super(context.merge(as_static_html: true))
|
||||
end
|
||||
|
||||
def self.format
|
||||
:markdown_as_static_html
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,7 +37,7 @@ module OpenProject::TextFormatting::Formats
|
||||
# and other channels where HTML would be a foreign body.
|
||||
class TextFormatter < OpenProject::TextFormatting::Formats::BaseFormatter
|
||||
def initialize(context)
|
||||
super(context.merge(plain_text: true))
|
||||
super(context.merge(as_text: true))
|
||||
end
|
||||
|
||||
def to_html(text)
|
||||
|
||||
@@ -104,12 +104,32 @@ module OpenProject::TextFormatting::Matchers
|
||||
label = WorkPackage::SemanticIdentifier.format(display_id)
|
||||
|
||||
return label if text_only?(work_package)
|
||||
return render_static_work_package_macro(work_package, label, detailed:) if context[:as_static_html]
|
||||
|
||||
ApplicationController.helpers.content_tag "opce-macro-wp-quickinfo",
|
||||
"",
|
||||
data: { id:, display_id:, detailed: }
|
||||
end
|
||||
|
||||
# Static fallback for channels that cannot hydrate the quickinfo
|
||||
# custom element (HTML mailers, exports). Mirrors the in-app widget's
|
||||
# text composition — type, optional status, formatted_id, subject —
|
||||
# so the anchor reads the same as the rich rendering once flattened.
|
||||
# Unresolved references collapse to the bare label.
|
||||
def render_static_work_package_macro(work_package, label, detailed:)
|
||||
return label unless work_package
|
||||
|
||||
parts = []
|
||||
parts << work_package.status&.name if detailed
|
||||
parts << work_package.type&.name
|
||||
parts << label
|
||||
link_text = "#{parts.compact.join(' ')}: #{work_package.subject}"
|
||||
|
||||
link_to(link_text,
|
||||
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
|
||||
@@ -133,7 +153,7 @@ module OpenProject::TextFormatting::Matchers
|
||||
# WP was preloaded — a nil work_package means a classic-mode render
|
||||
# or an unresolved reference, neither of which needs gating.
|
||||
def text_only?(work_package)
|
||||
context[:plain_text] || (work_package && !preload_cache.visible?(work_package.id))
|
||||
context[:as_text] || (work_package && !preload_cache.visible?(work_package.id))
|
||||
end
|
||||
|
||||
def preload_cache
|
||||
|
||||
@@ -163,17 +163,19 @@ module OpenProject::TextFormatting
|
||||
|
||||
# Doc-level preload called by `PatternMatcherFilter`. Save/restores
|
||||
# the cache 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)
|
||||
# re-entering the pipeline) doesn't clobber the outer render.
|
||||
# Classic mode normally skips the load (the link handler renders
|
||||
# `#N` from the matched id alone), but static-HTML channels need
|
||||
# the WP record in both modes to compose the type/subject/status
|
||||
# anchor.
|
||||
def self.with_preloaded_resources(doc, context)
|
||||
previous = RequestStore.store[CACHE_KEY]
|
||||
return yield unless Setting::WorkPackageIdentifier.semantic?
|
||||
return yield unless Setting::WorkPackageIdentifier.semantic? || context[:as_static_html]
|
||||
|
||||
identifiers = collect_work_package_identifiers(doc)
|
||||
return yield if identifiers.empty?
|
||||
|
||||
RequestStore.store[CACHE_KEY] = build_cache(identifiers)
|
||||
RequestStore.store[CACHE_KEY] = build_cache(identifiers, context)
|
||||
yield
|
||||
ensure
|
||||
RequestStore.store[CACHE_KEY] = previous
|
||||
@@ -214,9 +216,20 @@ module OpenProject::TextFormatting
|
||||
# one visibility-scoped id pluck. A third targeted SELECT fires
|
||||
# for historical aliases — the loaded row carries only the current
|
||||
# identifier, so unmapped inputs are filled in from
|
||||
# `WorkPackageSemanticAlias`.
|
||||
def self.build_cache(identifiers)
|
||||
work_packages = WorkPackage.where_display_id_in(*identifiers).select(:id, :identifier).to_a
|
||||
# `WorkPackageSemanticAlias`. Static-HTML channels also eager-load
|
||||
# `:type` and `:status` so the link handler can render the
|
||||
# static-anchor variant of `##`/`###` macros without N+1 queries —
|
||||
# those associations are the metadata a reader needs to recognise a
|
||||
# WP reference flattened to text. Anything beyond that (project,
|
||||
# versions, custom fields) stays out of this preload.
|
||||
def self.build_cache(identifiers, context = {})
|
||||
scope = WorkPackage.where_display_id_in(*identifiers)
|
||||
scope = if context[:as_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)
|
||||
|
||||
@@ -49,7 +49,7 @@ module OpenProject::TextFormatting
|
||||
.to_html(text)
|
||||
end
|
||||
|
||||
# @param [:plain, :markdown_as_text, :rich] format the text format.
|
||||
# @param [:plain, :markdown_as_text, :markdown_as_static_html, :rich] format the text format.
|
||||
# @return [Formats::BaseFormatter] a formatter implementation.
|
||||
def formatter_for(format)
|
||||
case format.to_sym
|
||||
@@ -57,6 +57,8 @@ module OpenProject::TextFormatting
|
||||
Formats.plain_formatter
|
||||
when :markdown_as_text
|
||||
Formats::Markdown::TextFormatter
|
||||
when :markdown_as_static_html
|
||||
Formats::Markdown::StaticHtmlFormatter
|
||||
else
|
||||
Formats.rich_formatter
|
||||
end
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# 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::Formats::Markdown::StaticHtmlFormatter do
|
||||
subject(:formatted) { described_class.new(context).to_html(input) }
|
||||
|
||||
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_flag: { semantic_work_package_ids: true },
|
||||
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_flag: { semantic_work_package_ids: true },
|
||||
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_flag: { semantic_work_package_ids: false },
|
||||
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_flag: { semantic_work_package_ids: true },
|
||||
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_flag: { semantic_work_package_ids: false },
|
||||
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_flag: { semantic_work_package_ids: true },
|
||||
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 = described_class.new(context).to_html("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 = described_class.new(context).to_html("see #{'###'}#{hidden_wp.id}")
|
||||
expect(rendered).to include("SECRET-1")
|
||||
expect(rendered).not_to match(%r{<a[^>]*>[^<]*SECRET-1})
|
||||
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_flag: { semantic_work_package_ids: true },
|
||||
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
|
||||
|
||||
describe "format identifier" do
|
||||
it "exposes :markdown_as_static_html" do
|
||||
expect(described_class.format).to eq(:markdown_as_static_html)
|
||||
end
|
||||
end
|
||||
|
||||
describe "renderer routing" do
|
||||
it "Renderer.formatter_for(:markdown_as_static_html) resolves to this class" do
|
||||
expect(OpenProject::TextFormatting::Renderer.formatter_for(:markdown_as_static_html))
|
||||
.to eq(described_class)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -324,5 +324,63 @@ RSpec.describe WorkPackageMailer do
|
||||
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_flag: { semantic_work_package_ids: true },
|
||||
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_flag: { semantic_work_package_ids: false },
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user