mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge branch 'release/17.5' into dev
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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
|
||||
|
||||
@@ -29,9 +29,20 @@
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
module EnvData
|
||||
class LdapSeeder < Seeder
|
||||
KNOWN_CONNECTION_KEYS = %w[
|
||||
host port security tls_verify tls_certificate sync_users
|
||||
filter basedn binduser bindpassword
|
||||
login_mapping firstname_mapping lastname_mapping mail_mapping admin_mapping
|
||||
groupfilter
|
||||
].freeze
|
||||
|
||||
KNOWN_FILTER_KEYS = %w[base filter sync_users group_attribute].freeze
|
||||
|
||||
def seed_data!
|
||||
print_status " ↳ Creating LDAP connection" do
|
||||
Setting.seed_ldap.each do |name, options|
|
||||
validate_options!(name, options)
|
||||
|
||||
ldap = LdapAuthSource.find_or_initialize_by(name:)
|
||||
|
||||
print_ldap_status(ldap)
|
||||
@@ -51,6 +62,34 @@ module EnvData
|
||||
|
||||
private
|
||||
|
||||
def validate_options!(name, options)
|
||||
check_unknown_keys!(options, KNOWN_CONNECTION_KEYS,
|
||||
scope: "LDAP connection '#{name}'")
|
||||
|
||||
filters = options["groupfilter"]
|
||||
return if filters.blank?
|
||||
|
||||
filters.each do |filter_name, filter_options|
|
||||
check_unknown_keys!(filter_options, KNOWN_FILTER_KEYS,
|
||||
scope: "LDAP group filter '#{filter_name}' (connection '#{name}')")
|
||||
end
|
||||
end
|
||||
|
||||
def check_unknown_keys!(options, known_keys, scope:)
|
||||
unknown = options.keys - known_keys
|
||||
return if unknown.empty?
|
||||
|
||||
raise <<~MSG.strip
|
||||
#{scope}: unknown configuration key(s): #{unknown.map { |k| env_form(k) }.join(', ')}.
|
||||
Accepted keys: #{known_keys.map { |k| env_form(k) }.join(', ')}.
|
||||
Note: in environment variable names, single underscores split path segments and double underscores encode a literal underscore (e.g. LOGIN__MAPPING, not LOGIN_MAPPING).
|
||||
MSG
|
||||
end
|
||||
|
||||
def env_form(key)
|
||||
key.gsub("_", "__").upcase
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def upsert_settings(ldap, options)
|
||||
ldap.host = options["host"]
|
||||
|
||||
@@ -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
|
||||
) %>
|
||||
|
||||
@@ -3649,16 +3649,18 @@ en:
|
||||
learn_about: "Learn more about all new features"
|
||||
missing: "There are no highlighted features yet."
|
||||
# We need to include the version to invalidate outdated translations in other locales
|
||||
"17_4":
|
||||
"17_5":
|
||||
new_features_title: >
|
||||
The release contains various new features and improvements, such as:
|
||||
new_features_list:
|
||||
line_0: "Jira Migrator with support for basic custom fields."
|
||||
line_1: Backlog buckets for structuring and prioritizing work packages during backlog refinement.
|
||||
line_2: Easier drag and drop and improved move options in the Backlogs module.
|
||||
line_3: Sprint Start and Complete buttons in the sprint header.
|
||||
line_4: Copy workflow settings between roles.
|
||||
line_5: "'My Meetings' widget on the Home and Project Overview pages."
|
||||
line_0: "Project-based work package identifiers for clearer references."
|
||||
line_1: Jira Migrator support for Jira identifiers, due dates, and more.
|
||||
line_2: Option to exclude work package types from Backlogs.
|
||||
line_3: Redesigned sprint views.
|
||||
line_4: Improved work package linking across Documents and text editors.
|
||||
line_5: More flexible meeting schedules and reduced email notification noise.
|
||||
line_6: Nested groups for organizational structures and inherited permissions.
|
||||
line_7: Improved administration interfaces for workflows, users, and type configuration.
|
||||
links:
|
||||
upgrade_enterprise_edition: "Upgrade to Enterprise edition"
|
||||
postgres_migration: "Migrating your installation to PostgreSQL"
|
||||
|
||||
@@ -229,43 +229,65 @@ The connection can be set with the following options. Please note that "EXAMPLE"
|
||||
|
||||
The name of the LDAP connection is derived from the ENV key behind `SEED_LDAP_`, so you need to take care to use only valid characters. If you need to place an underscore, use a double underscore to encode it e.g., `my__ldap`.
|
||||
|
||||
The following options are possible
|
||||
#### Naming rule for option keys
|
||||
|
||||
The same encoding applies to **option keys** (the part of the env var after the connection name):
|
||||
|
||||
- A single underscore (`_`) separates path segments.
|
||||
- A double underscore (`__`) encodes a literal underscore inside a single key.
|
||||
|
||||
For example, the attribute mapping for the login attribute must be passed as `LOGIN__MAPPING`. Writing `LOGIN_MAPPING` would create a nested `login.mapping` hash and would have been silently wrong in OpenProject versions earlier than 17.5.
|
||||
|
||||
Starting with OpenProject 17.5 the seeder validates the keys it sees and raises an error listing any unknown key, so typos no longer go unnoticed. Use exactly the keys shown below.
|
||||
|
||||
#### Full example
|
||||
|
||||
The example below shows every supported option for a connection named `EXAMPLE`.
|
||||
|
||||
```shell
|
||||
# Host name of the connection
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_HOST="localhost"
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_HOST="ldap.example.com"
|
||||
|
||||
# Port of the connection
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_PORT="389"
|
||||
# LDAP security options. One of the following
|
||||
# plain_ldap: Unencrypted connection, no TLS/SSL
|
||||
# simple_tls: Using deprecated LDAPS/SSL (often in combination with port 636)
|
||||
# start_tls: LDAPv3 start_tls call using standard unencrypted port (e.g., 389) before upgrading connection
|
||||
|
||||
# LDAP security mode. One of:
|
||||
# plain_ldap: Unencrypted connection, no TLS/SSL
|
||||
# simple_tls: Deprecated LDAPS/SSL (often combined with port 636)
|
||||
# start_tls: LDAPv3 STARTTLS on the standard unencrypted port (e.g., 389)
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_SECURITY="start_tls"
|
||||
# Whether to verify the certificate/chain of the LDAP connection. true/false (True by default)
|
||||
|
||||
# Whether to verify the LDAP server's certificate chain. true/false (true by default).
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_TLS__VERIFY="true"
|
||||
# Optionally, provide a certificate of the connection
|
||||
|
||||
# Optionally pin a server certificate (PEM-encoded).
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_TLS__CERTIFICATE="-----BEGIN CERTIFICATE-----\nMII....\n-----END CERTIFICATE-----"
|
||||
# The admin LDAP bind account with read access
|
||||
|
||||
# Bind DN (the LDAP account used for read access during search/bind).
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_BINDUSER="uid=admin,ou=system"
|
||||
# Password for the bind account
|
||||
|
||||
# Password for the bind account.
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_BINDPASSWORD="secret"
|
||||
# BASE DN of the connection
|
||||
|
||||
# Base DN of the directory subtree used for user search.
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_BASEDN="dc=example,dc=com"
|
||||
# Optional filter string to restrict which users may log in to OpenProject
|
||||
# (relevant when for automatic creation of users is active)
|
||||
|
||||
# Optional LDAP filter restricting which users may log in to OpenProject
|
||||
# (used when automatic user creation is active).
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_FILTER="(uid=*)"
|
||||
# Whether to create found and matching users automatically when they log in
|
||||
|
||||
# Whether to create matching users on the fly when they log in for the first time.
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_SYNC__USERS="true"
|
||||
# Attribute mapping for the OpenProject login attribute
|
||||
|
||||
# Attribute mappings: which LDAP attribute should populate each OpenProject field.
|
||||
# Remember the double underscore in these keys.
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_LOGIN__MAPPING="uid"
|
||||
# Attribute mapping for the OpenProject first name attribute
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_FIRSTNAME__MAPPING="givenName"
|
||||
# Attribute mapping for the OpenProject last name attribute
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_LASTNAME__MAPPING="sn"
|
||||
# Attribute mapping for the OpenProject mail attribute
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_MAIL__MAPPING="mail"
|
||||
# Attribute mapping for the OpenProject admin attribute
|
||||
# Leave empty or remove to not derive admin status from an attribute
|
||||
|
||||
# Optional: derive admin status from an LDAP attribute. Leave empty or remove
|
||||
# to keep admin status managed manually in OpenProject.
|
||||
OPENPROJECT_SEED_LDAP_EXAMPLE_ADMIN__MAPPING=""
|
||||
```
|
||||
|
||||
|
||||
+16
-2
@@ -1,5 +1,10 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { displayClassName, editableClassName, readOnlyClassName } from 'core-app/shared/components/fields/display/display-field-renderer';
|
||||
import {
|
||||
displayClassName,
|
||||
displayTriggerLink,
|
||||
editableClassName,
|
||||
readOnlyClassName,
|
||||
} from 'core-app/shared/components/fields/display/display-field-renderer';
|
||||
import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service';
|
||||
import { getPosition } from 'core-app/shared/helpers/set-click-position/set-click-position';
|
||||
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
|
||||
@@ -38,6 +43,15 @@ export class EditCellHandler extends ClickOrEnterHandler implements TableEventHa
|
||||
|
||||
protected processEvent(table:WorkPackageTable, evt:MouseEvent|KeyboardEvent):void {
|
||||
debugLog('Starting editing on cell: ', evt.target);
|
||||
|
||||
// Don't intercept clicks on anchor elements - let the browser follow the link
|
||||
const clickTarget = evt.target as HTMLElement;
|
||||
const foundElement = clickTarget.closest(`a:not(.${displayTriggerLink}),macro`);
|
||||
if (foundElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
evt.preventDefault();
|
||||
|
||||
// Locate the cell from event
|
||||
@@ -61,7 +75,7 @@ export class EditCellHandler extends ClickOrEnterHandler implements TableEventHa
|
||||
// Get any existing edit state for this work package
|
||||
const form = table.editing.startEditing(workPackage, classIdentifier);
|
||||
|
||||
let positionOffset = 0;
|
||||
let positionOffset = 0;
|
||||
if (evt.type === 'click') {
|
||||
// Get the position where the user clicked.
|
||||
positionOffset = getPosition(evt as MouseEvent);
|
||||
|
||||
@@ -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
|
||||
@@ -54,6 +54,7 @@ class MeetingsController < ApplicationController
|
||||
include OpTurbo::FlashStreamHelper
|
||||
include Meetings::AgendaComponentStreams
|
||||
include MetaTagsHelper
|
||||
include FlashMessagesOutputSafetyHelper
|
||||
|
||||
menu_item :new_meeting, only: %i[new create]
|
||||
|
||||
@@ -311,12 +312,14 @@ class MeetingsController < ApplicationController
|
||||
@meeting.in_progress!
|
||||
end
|
||||
|
||||
if @meeting.errors.any?
|
||||
update_sidebar_state_component_via_turbo_stream
|
||||
else
|
||||
update_all_via_turbo_stream
|
||||
update_backlog_via_turbo_stream(collapsed: nil)
|
||||
end
|
||||
update_all_via_turbo_stream
|
||||
update_backlog_via_turbo_stream(collapsed: nil)
|
||||
|
||||
respond_with_turbo_streams
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
@meeting.errors.add(:base, :error_conflict)
|
||||
update_sidebar_state_component_via_turbo_stream
|
||||
render_error_flash_message_via_turbo_stream(message: join_flash_messages(@meeting.errors))
|
||||
|
||||
respond_with_turbo_streams
|
||||
end
|
||||
|
||||
@@ -50,5 +50,15 @@ RSpec.describe "Meeting requests",
|
||||
|
||||
expect(meeting.reload).to be_in_progress
|
||||
end
|
||||
|
||||
it "handles a stale object gracefully (Bug #68703)" do
|
||||
allow_any_instance_of(Meeting).to receive(:in_progress!).and_raise(ActiveRecord::StaleObjectError) # rubocop:disable RSpec/AnyInstance
|
||||
|
||||
put change_state_project_meeting_path(project, meeting, state: "in_progress"),
|
||||
as: :turbo_stream
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(meeting.reload).to be_open
|
||||
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
|
||||
|
||||
|
||||
@@ -248,4 +248,51 @@ RSpec.describe EnvData::LdapSeeder do
|
||||
expect(names).to contain_exactly("another")
|
||||
end
|
||||
end
|
||||
|
||||
context "when an unknown key is provided",
|
||||
:settings_reset,
|
||||
with_env: {
|
||||
OPENPROJECT_SEED_LDAP_FOO_HOST: "localhost",
|
||||
OPENPROJECT_SEED_LDAP_FOO_PORT: "12389",
|
||||
OPENPROJECT_SEED_LDAP_FOO_SECURITY: "plain_ldap",
|
||||
OPENPROJECT_SEED_LDAP_FOO_BINDUSER: "uid=admin,ou=system",
|
||||
OPENPROJECT_SEED_LDAP_FOO_BINDPASSWORD: "secret",
|
||||
OPENPROJECT_SEED_LDAP_FOO_BASEDN: "dc=example,dc=com",
|
||||
OPENPROJECT_SEED_LDAP_FOO_LOGIN__MAPPING: "uid",
|
||||
OPENPROJECT_SEED_LDAP_FOO_FIRSTNAME__MAPPING: "givenName",
|
||||
OPENPROJECT_SEED_LDAP_FOO_LASTNAME__MAPPING: "sn",
|
||||
OPENPROJECT_SEED_LDAP_FOO_MAIL__MAPPING: "mail",
|
||||
OPENPROJECT_SEED_LDAP_FOO_NONSENSE: "boom"
|
||||
} do
|
||||
it "raises an error naming the unknown key without creating the record" do
|
||||
reset(:seed_ldap)
|
||||
|
||||
expect { seeder.seed! }
|
||||
.to raise_error(/LDAP connection 'foo'.*NONSENSE/m)
|
||||
.and not_change(LdapAuthSource, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context "when a typo'd nested key would be silently swallowed",
|
||||
:settings_reset,
|
||||
with_env: {
|
||||
OPENPROJECT_SEED_LDAP_FOO_HOST: "localhost",
|
||||
OPENPROJECT_SEED_LDAP_FOO_PORT: "12389",
|
||||
OPENPROJECT_SEED_LDAP_FOO_SECURITY: "plain_ldap",
|
||||
OPENPROJECT_SEED_LDAP_FOO_BINDUSER: "uid=admin,ou=system",
|
||||
OPENPROJECT_SEED_LDAP_FOO_BINDPASSWORD: "secret",
|
||||
OPENPROJECT_SEED_LDAP_FOO_BASEDN: "dc=example,dc=com",
|
||||
OPENPROJECT_SEED_LDAP_FOO_LOGIN_MAPPING: "uid",
|
||||
OPENPROJECT_SEED_LDAP_FOO_FIRSTNAME__MAPPING: "givenName",
|
||||
OPENPROJECT_SEED_LDAP_FOO_LASTNAME__MAPPING: "sn",
|
||||
OPENPROJECT_SEED_LDAP_FOO_MAIL__MAPPING: "mail"
|
||||
} do
|
||||
it "raises rather than silently producing a nil login attribute" do
|
||||
reset(:seed_ldap)
|
||||
|
||||
expect { seeder.seed! }
|
||||
.to raise_error(/LDAP connection 'foo'.*LOGIN/m)
|
||||
.and not_change(LdapAuthSource, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,8 +80,10 @@ module Components
|
||||
end
|
||||
|
||||
def save
|
||||
find('[data-test-selector="spot-modal-wp-table-configuration-save-button"]').click
|
||||
expect_closed
|
||||
retry_block do
|
||||
find('[data-test-selector="spot-modal-wp-table-configuration-save-button"]').click
|
||||
expect_closed
|
||||
end
|
||||
|
||||
if using_cuprite?
|
||||
wait_for_reload
|
||||
|
||||
Reference in New Issue
Block a user