diff --git a/Gemfile b/Gemfile
index f319e751a1e3..11fc578e9120 100644
--- a/Gemfile
+++ b/Gemfile
@@ -157,9 +157,11 @@ gem "structured_warnings", "~> 0.4.0"
# don't require by default, instead load on-demand when actually configured
gem "airbrake", "~> 13.0.0", require: false
+gem "markly", "~> 0.10" # another markdown parser like commonmarker, but with AST support used in PDF export
gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "8f14736a88ad0064d2a97be108fe7061ffbcee91"
gem "prawn", "~> 2.4"
gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues/1346 resolved.
+
# prawn implicitly depends on matrix gem no longer in ruby core with 3.1
gem "matrix", "~> 0.4.2"
diff --git a/Gemfile.lock b/Gemfile.lock
index ae6d9b8b4002..3263c39e6a86 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1237,6 +1237,7 @@ DEPENDENCIES
lograge (~> 0.14.0)
lookbook (~> 2.3.0)
mail (= 2.8.1)
+ markly (~> 0.10)
matrix (~> 0.4.2)
md_to_pdf!
meta-tags (~> 2.21.0)
diff --git a/app/models/exports/formatters/custom_field.rb b/app/models/exports/formatters/custom_field.rb
index 5815fb2a18ff..ef55bd0aff25 100644
--- a/app/models/exports/formatters/custom_field.rb
+++ b/app/models/exports/formatters/custom_field.rb
@@ -1,12 +1,12 @@
module Exports
module Formatters
class CustomField < Default
- def self.apply?(attribute, _export_format)
- attribute.start_with?("cf_")
+ def self.apply?(attribute, export_format)
+ export_format != :pdf && attribute.start_with?("cf_")
end
##
- # Takes a WorkPackage and an attribute and returns the value to be exported.
+ # Takes a WorkPackage or Project and an attribute and returns the value to be exported.
def retrieve_value(object)
custom_field = find_custom_field(object)
return "" if custom_field.nil?
diff --git a/app/models/exports/formatters/custom_field_pdf.rb b/app/models/exports/formatters/custom_field_pdf.rb
new file mode 100644
index 000000000000..a299f785a169
--- /dev/null
+++ b/app/models/exports/formatters/custom_field_pdf.rb
@@ -0,0 +1,27 @@
+module Exports
+ module Formatters
+ class CustomFieldPdf < CustomField
+ def self.apply?(attribute, export_format)
+ export_format == :pdf && attribute.start_with?("cf_")
+ end
+
+ ##
+ # Print the value meant for export.
+ #
+ # - For boolean values, use the Yes/No formatting for the PDF
+ # treat nil as false
+ # - For long text values, output the plain value
+ def format_for_export(object, custom_field)
+ case custom_field.field_format
+ when "bool"
+ value = object.typed_custom_value_for(custom_field)
+ value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+ when "text"
+ object.typed_custom_value_for(custom_field)
+ else
+ object.formatted_custom_value_for(custom_field)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/projects/exports/formatters/active.rb b/app/models/projects/exports/formatters/active.rb
new file mode 100644
index 000000000000..93a78add3542
--- /dev/null
+++ b/app/models/projects/exports/formatters/active.rb
@@ -0,0 +1,42 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 Projects::Exports
+ module Formatters
+ class Active < ::Exports::Formatters::Default
+ def self.apply?(attribute, export_format)
+ export_format == :pdf && %i[active].include?(attribute.to_sym)
+ end
+
+ ##
+ # Takes a project and returns yes/no depending on the active attribute
+ def format(project, **)
+ project.active? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+ end
+ end
+ end
+end
diff --git a/app/models/projects/exports/formatters/public.rb b/app/models/projects/exports/formatters/public.rb
new file mode 100644
index 000000000000..deb4deb03011
--- /dev/null
+++ b/app/models/projects/exports/formatters/public.rb
@@ -0,0 +1,42 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 Projects::Exports
+ module Formatters
+ class Public < ::Exports::Formatters::Default
+ def self.apply?(attribute, export_format)
+ export_format == :pdf && %i[public].include?(attribute.to_sym)
+ end
+
+ ##
+ # Takes a project and returns yes/no depending on the public attribute
+ def format(project, **)
+ project.public? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No)
+ end
+ end
+ end
+end
diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
new file mode 100644
index 000000000000..8b50adfa503e
--- /dev/null
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -0,0 +1,172 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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 WorkPackage::Exports
+ module Macros
+ # OpenProject attribute macros syntax
+ # Examples:
+ # workPackageLabel:subject # Outputs work package label attribute "Subject"
+ # workPackageLabel:1234:subject # Outputs work package label attribute "Subject"
+
+ # workPackageValue:subject # Outputs the subject of the current work package
+ # workPackageValue:1234:subject # Outputs the subject of #1234
+ # workPackageValue:"custom field name" # Outputs the custom field value of the current work package
+ # workPackageValue:1234:"custom field name" # Outputs the custom field value of #1234
+ #
+ # projectLabel:active # Outputs current project label attribute "active"
+ # projectLabel:1234:active # Outputs project label attribute "active"
+ # projectLabel:my-project-identifier:active # Outputs project label attribute "active"
+
+ # projectValue:active # Outputs current project value for "active"
+ # projectValue:1234:active # Outputs project with id 1234 value for "active"
+ # projectValue:my-project-identifier:active # Outputs project with identifier my-project-identifier value for "active"
+ class Attributes < OpenProject::TextFormatting::Matchers::RegexMatcher
+ DISABLED_PROJECT_RICH_TEXT_FIELDS = %i[description status_explanation status_description].freeze
+ DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS = %i[description].freeze
+
+ def self.regexp
+ %r{
+ (\w+)(Label|Value) # The model type we try to reference
+ (?::(?:([^"\s]+)|"([^"]+)"))? # Optional: An ID or subject reference
+ (?::([^"\s.]+|"([^".]+)")) # The attribute name we're trying to reference
+ }x
+ end
+
+ ##
+ # Faster inclusion check before the regex is being applied
+ def self.applicable?(content)
+ content.include?("Label:") || content.include?("Value:")
+ end
+
+ def self.process_match(match, _matched_string, context)
+ context => { user:, work_package: }
+ type = match[2].downcase
+ model_s = match[1]
+ id = match[4] || match[3]
+ attribute = match[6] || match[5]
+ resolve_match(type, model_s, id, attribute, work_package, user)
+ end
+
+ def self.resolve_match(type, model_s, id, attribute, work_package, user)
+ if model_s == "workPackage"
+ resolve_work_package_match(id || work_package.id, type, attribute, user)
+ elsif model_s == "project"
+ resolve_project_match(id || work_package.project.id, type, attribute, user)
+ else
+ msg_macro_error I18n.t('export.macro.model_not_found', model: model_s)
+ end
+ end
+
+ def self.msg_macro_error(message)
+ msg_inline I18n.t('export.macro.error', message:)
+ end
+
+ def self.msg_macro_error_rich_text
+ msg_inline I18n.t('export.macro.rich_text_unsupported')
+ end
+
+ def self.msg_inline(message)
+ "[#{message}]"
+ end
+
+ def self.resolve_label_work_package(attribute)
+ resolve_label(WorkPackage, attribute)
+ end
+
+ def self.resolve_label_project(attribute)
+ resolve_label(Project, attribute)
+ end
+
+ def self.resolve_label(model, attribute)
+ model.human_attribute_name(
+ ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: model.new)
+ )
+ end
+
+ def self.resolve_work_package_match(id, type, attribute, user)
+ return resolve_label_work_package(attribute) if type == "label"
+ return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value"
+
+ work_package = WorkPackage.visible(user).find_by(id:)
+ if work_package.nil?
+ return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}"))
+ end
+
+ resolve_value_work_package(work_package, attribute)
+ end
+
+ def self.resolve_project_match(id, type, attribute, user)
+ return resolve_label_project(attribute) if type == "label"
+ return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value"
+
+ project = Project.visible(user).find_by(id:)
+ project = Project.visible(user).find_by(identifier: id) if project.nil?
+ if project.nil?
+ return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}"))
+ end
+
+ resolve_value_project(project, attribute)
+ end
+
+ def self.escape_tags(value)
+ # only disable html tags, but do not replace html entities
+ value.to_s.gsub("<", "<").gsub(">", ">")
+ end
+
+ def self.resolve_value_project(project, attribute)
+ resolve_value(project, attribute, DISABLED_PROJECT_RICH_TEXT_FIELDS)
+ end
+
+ def self.resolve_value_work_package(work_package, attribute)
+ resolve_value(work_package, attribute, DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS)
+ end
+
+ def self.resolve_value(obj, attribute, disabled_rich_text_fields)
+ cf = obj.available_custom_fields.find { |pcf| pcf.name == attribute }
+
+ return msg_macro_error_rich_text if cf&.formattable?
+
+ ar_name = if cf.nil?
+ ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: obj)
+ else
+ "cf_#{cf.id}"
+ end
+ return msg_macro_error_rich_text if disabled_rich_text_fields.include?(ar_name.to_sym)
+
+ format_attribute_value(ar_name, obj.class, obj)
+ end
+
+ def self.format_attribute_value(ar_name, model, obj)
+ formatter = Exports::Register.formatter_for(model, ar_name, :pdf)
+ value = formatter.format(obj)
+ # important NOT to return empty string as this could change meaning of markdown
+ # e.g. **to_be_replaced** could be rendered as **** (horizontal line and a *)
+ value.blank? ? " " : escape_tags(value)
+ end
+ end
+ end
+end
diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb
index 963396d30067..f5d45baa1dd5 100644
--- a/app/models/work_package/pdf_export/markdown_field.rb
+++ b/app/models/work_package/pdf_export/markdown_field.rb
@@ -28,6 +28,7 @@
module WorkPackage::PDFExport::MarkdownField
include WorkPackage::PDFExport::Markdown
+ PREFORMATTED_BLOCKS = %w(pre code).freeze
def write_markdown_field!(work_package, markdown, label)
return if markdown.blank?
@@ -37,7 +38,52 @@ def write_markdown_field!(work_package, markdown, label)
pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })])
end
with_margin(styles.wp_markdown_margins) do
- write_markdown! work_package, markdown
+ write_markdown! work_package, apply_markdown_field_macros(markdown, work_package)
+ end
+ end
+
+ private
+
+ def apply_markdown_field_macros(markdown, work_package)
+ apply_macros(markdown, work_package, WorkPackage::Exports::Macros::Attributes)
+ end
+
+ def apply_macros(markdown, work_package, formatter)
+ return markdown unless formatter.applicable?(markdown)
+
+ document = Markly.parse(markdown)
+ document.walk do |node|
+ if node.type == :html
+ node.string_content = apply_macro_html(node.string_content, work_package, formatter) || node.string_content
+ elsif node.type == :text
+ node.string_content = apply_macro_text(node.string_content, work_package, formatter) || node.string_content
+ end
+ end
+ document.to_markdown
+ end
+
+ def apply_macro_text(text, work_package, formatter)
+ return text unless formatter.applicable?(text)
+
+ text.gsub!(formatter.regexp) do |matched_string|
+ matchdata = Regexp.last_match
+ formatter.process_match(matchdata, matched_string, { user: User.current, work_package: })
+ end
+ end
+
+ def apply_macro_html(html, work_package, formatter)
+ return html unless formatter.applicable?(html)
+
+ doc = Nokogiri::HTML.fragment(html)
+ apply_macro_html_node(doc, work_package, formatter)
+ doc.to_html
+ end
+
+ def apply_macro_html_node(node, work_package, formatter)
+ if node.text?
+ node.content = apply_macro_text(node.content, work_package, formatter)
+ elsif PREFORMATTED_BLOCKS.exclude?(node.name)
+ node.children.each { |child| apply_macro_html_node(child, work_package, formatter) }
end
end
end
diff --git a/app/models/work_package/pdf_export/work_package_detail.rb b/app/models/work_package/pdf_export/work_package_detail.rb
index 2d4aae33e8e7..632ea724c8fc 100644
--- a/app/models/work_package/pdf_export/work_package_detail.rb
+++ b/app/models/work_package/pdf_export/work_package_detail.rb
@@ -135,15 +135,19 @@ def form_configuration_columns(work_package)
end.flatten
end
- def form_key_to_column_entries(form_key, work_package)
- if CustomField.custom_field_attribute? form_key
- id = form_key.to_s.sub("custom_field_", "").to_i
- cf = CustomField.find_by(id:)
- return [] if cf.nil? || cf.formattable?
+ def form_key_custom_field_to_column_entries(form_key, work_package)
+ id = form_key.to_s.sub("custom_field_", "").to_i
+ cf = CustomField.find_by(id:)
+ return [] if cf.nil? || cf.formattable?
+
+ return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id)
- return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id)
+ [{ label: cf.name || form_key, name: form_key.to_s.sub("custom_field_", "cf_") }]
+ end
- return [{ label: cf.name || form_key, name: form_key }]
+ def form_key_to_column_entries(form_key, work_package)
+ if CustomField.custom_field_attribute? form_key
+ return form_key_custom_field_to_column_entries(form_key, work_package)
end
if form_key == :date
diff --git a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb
index 7de6d091f45a..f933b5f6ee7d 100644
--- a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb
+++ b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb
@@ -73,7 +73,7 @@ def export!
rescue Prawn::Errors::CannotFit
error(I18n.t(:error_pdf_export_too_many_columns))
rescue StandardError => e
- Rails.logger.error { "Failed to generated PDF export: #{e}." }
+ Rails.logger.error { "Failed to generate PDF export: #{e}." }
error(I18n.t(:error_pdf_failed_to_export, error: e.message[0..300]))
end
diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb
index f95759cdf163..9d7603cef5e9 100644
--- a/config/initializers/export_formats.rb
+++ b/config/initializers/export_formats.rb
@@ -15,11 +15,15 @@
formatter WorkPackage, WorkPackage::Exports::Formatters::Costs
formatter WorkPackage, WorkPackage::Exports::Formatters::DoneRatio
formatter WorkPackage, Exports::Formatters::CustomField
+ formatter WorkPackage, Exports::Formatters::CustomFieldPdf
list Project, Projects::Exports::CSV
formatter Project, Exports::Formatters::CustomField
+ formatter Project, Exports::Formatters::CustomFieldPdf
formatter Project, Projects::Exports::Formatters::Status
formatter Project, Projects::Exports::Formatters::Description
+ formatter Project, Projects::Exports::Formatters::Public
+ formatter Project, Projects::Exports::Formatters::Active
end
end
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4d41c3b0f388..546bfdb71ed5 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1716,6 +1716,12 @@ Project attributes and sections are defined in the true,
+ project_custom_field_long_text.id => "foo",
+ },
+ work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool],
+ work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled
+ end
+ let(:forbidden_project) do
+ create(:project,
+ name: "Forbidden project",
+ types: [type],
+ id: 666,
+ identifier: "forbidden-project",
+ public: false,
+ status_code: "on_track",
+ active: true,
+ parent: parent_project,
work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool],
work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled
end
@@ -47,12 +84,20 @@
create(:user,
member_with_permissions: { project => %w[view_work_packages export_work_packages] })
end
+ let(:another_user) do
+ create(:user, firstname: "Secret User")
+ end
+ let(:category) { create(:category, project:, name: "Demo") }
+ let(:version) { create(:version, project:) }
let(:export_time) { DateTime.new(2023, 6, 30, 23, 59) }
let(:export_time_formatted) { format_time(export_time, true) }
let(:image_path) { Rails.root.join("spec/fixtures/files/image.png") }
+ let(:priority) { create(:priority_normal) }
let(:image_attachment) { Attachment.new author: user, file: File.open(image_path) }
let(:attachments) { [image_attachment] }
- let(:cf_long_text) { create(:issue_custom_field, :text, name: "LongText") }
+ let(:cf_long_text_description) { "" }
+ let(:cf_long_text) { create(:issue_custom_field, :text,
+ name: "Work Package Custom Field Long Text") }
let!(:cf_disabled_in_project) do
# NOT enabled by project.work_package_custom_field_ids => NOT in PDF
create(:float_wp_custom_field, name: "DisabledCustomField")
@@ -60,13 +105,16 @@
let(:cf_global_bool) do
create(
:work_package_custom_field,
+ name: "Work Package Custom Field Boolean",
field_format: "bool",
is_for_all: true,
default_value: true
)
end
- let(:work_package) do
- description = <<~DESCRIPTION
+ let(:status) { create(:status, name: "random", is_default: true) }
+ let!(:parent_work_package) { create(:work_package, type:, subject: "Parent wp") }
+ let(:description) do
+ <<~DESCRIPTION
**Lorem** _ipsum_ ~~dolor~~ `sit` [amet](https://example.com/), consetetur sadipscing elitr.
@OpenProject Admin

@@ -80,14 +128,32 @@
Foo
DESCRIPTION
+ end
+ let(:work_package) do
create(:work_package,
+ id: 1,
project:,
type:,
subject: "Work package 1",
+ start_date: "2024-05-30",
+ due_date: "2024-05-30",
+ created_at: export_time,
+ updated_at: export_time,
+ author: user,
+ assigned_to: user,
+ responsible: user,
story_points: 1,
+ estimated_hours: 10,
+ done_ratio: 25,
+ remaining_hours: 9,
+ parent: parent_work_package,
+ priority:,
+ version:,
+ status:,
+ category:,
description:,
custom_values: {
- cf_long_text.id => "foo",
+ cf_long_text.id => cf_long_text_description,
cf_disabled_in_project.id => "6.25",
cf_global_bool.id => true
}).tap do |wp|
@@ -96,6 +162,24 @@
.and_return attachments
end
end
+ let(:forbidden_work_package) do
+ create(:work_package,
+ id: 10,
+ project: forbidden_project,
+ type:,
+ subject: "forbidden Work package",
+ start_date: "2024-05-30",
+ due_date: "2024-05-30",
+ created_at: export_time,
+ updated_at: export_time,
+ author: another_user,
+ assigned_to: another_user
+ ).tap do |wp|
+ allow(wp)
+ .to receive(:attachments)
+ .and_return attachments
+ end
+ end
let(:options) { {} }
let(:exporter) do
described_class.new(work_package, options)
@@ -109,6 +193,16 @@
export.export!
end
end
+ let(:expected_details) do
+ ["#{type.name} ##{work_package.id} - #{work_package.subject}"] +
+ exporter.send(:attributes_data_by_wp, work_package)
+ .flat_map do |item|
+ value = get_column_value(item[:name])
+ result = [item[:label].upcase]
+ result << value if value.present?
+ result
+ end
+ end
def get_column_value(column_name)
formatter = Exports::Register.formatter_for(WorkPackage, column_name, :pdf)
@@ -125,30 +219,203 @@ def get_column_value(column_name)
end
describe "with a request for a PDF" do
- it "contains correct data" do
- details = exporter.send(:attributes_data_by_wp, work_package)
- .flat_map do |item|
- value = get_column_value(item[:name])
- result = [item[:label].upcase]
- result << value if value.present?
- result
+ describe "with rich text and images" do
+ let(:cf_long_text_description) { "foo" }
+ it "contains correct data" do
+ result = pdf[:strings]
+ expected_result = [
+ *expected_details,
+ label_title(:description),
+ "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ",
+ "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin",
+ "Image Caption",
+ "Foo",
+ cf_long_text.name, "foo",
+ "1", export_time_formatted, project.name
+ ].flatten
+ # Joining the results for comparison since word wrapping leads to a different array for the same content
+ expect(result.join(" ")).to eq(expected_result.join(" "))
+ expect(result.join(" ")).not_to include("DisabledCustomField")
+ expect(pdf[:images].length).to eq(2)
+ end
+ end
+
+ describe "with embedded work package attributes" do
+ let(:supported_work_package_embeds) do
+ [
+ ["assignee", user.name],
+ ["author", user.name],
+ ["category", category.name],
+ ["createdAt", export_time_formatted],
+ ["updatedAt", export_time_formatted],
+ ["estimatedTime", "10.0 h"],
+ ["remainingTime", "9.0 h"],
+ ["version", version.name],
+ ["responsible", user.name],
+ ["dueDate", "05/30/2024"],
+ ["spentTime", "0.0 h"],
+ ["startDate", "05/30/2024"],
+ ["parent", "#{type.name} ##{parent_work_package.id}: #{parent_work_package.name}"],
+ ["priority", priority.name],
+ ["project", project.name],
+ ["status", status.name],
+ ["subject", "Work package 1"],
+ ["type", type.name],
+ ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"]
+ ]
+ end
+ let(:supported_work_package_embeds_table) do
+ supported_work_package_embeds.map do |embed|
+ "workPackageLabel:#{embed[0]} | workPackageValue:#{embed[0]} |
"
+ end
+ end
+ let(:description) do
+ <<~DESCRIPTION
+ ## Work package attributes and labels
+ #{supported_work_package_embeds_table}
+ Custom field boolean |
+ workPackageValue:1:"#{cf_global_bool.name}"
+ |
+ Custom field rich text |
+ workPackageValue:1:"#{cf_long_text.name}"
+ |
+ No replacement of: |
+ workPackageValue:1:assignee
+ workPackageLabel:assignee
+ |
+
+
+ `workPackageValue:2:assignee workPackageLabel:assignee`
+
+ ```
+ workPackageValue:3:assignee
+ workPackageLabel:assignee
+ ```
+
+ Work package not found:
+ workPackageValue:1234567890:assignee
+ Access denied:
+ workPackageValue:#{forbidden_work_package.id}:assignee
+ DESCRIPTION
+ end
+ it "contains resolved attributes and labels" do
+ result = pdf[:strings]
+ expected_result = [
+ *expected_details,
+ label_title(:description),
+ "Work package attributes and labels",
+ supported_work_package_embeds.map do |embed|
+ [WorkPackage.human_attribute_name(
+ API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: work_package)
+ ), embed[1]]
+ end,
+ "Custom field boolean", I18n.t(:general_text_Yes),
+ "1", export_time_formatted, project.name,
+ "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]",
+ "No replacement of:", "workPackageValue:1:assignee", " ", "workPackageLabel:assignee",
+ "workPackageValue:2:assignee workPackageLabel:assignee",
+ "workPackageValue:3:assignee", "workPackageLabel:assignee",
+ "Work package not found: ",
+ "[#{I18n.t('export.macro.error', message:
+ I18n.t('export.macro.resource_not_found', resource: "WorkPackage 1234567890"))}] ",
+ "Access denied: ",
+ "[#{I18n.t('export.macro.error', message:
+ I18n.t('export.macro.resource_not_found', resource: "WorkPackage #{forbidden_work_package.id}"))}]",
+ "2", export_time_formatted, project.name
+ ].flatten
+ expect(result.join(" ")).to eq(expected_result.join(" "))
+ end
+ end
+
+ describe "with embedded project attributes" do
+ let(:supported_project_embeds) do
+ [
+ ["active", I18n.t(:general_text_Yes)],
+ ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"],
+ ["identifier", project.identifier],
+ ["name", project.name],
+ ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")],
+ ["statusExplanation", "[#{I18n.t('export.macro.rich_text_unsupported')}]"],
+ ["parent", parent_project.name],
+ ["public", I18n.t(:general_text_Yes)]
+ ]
+ end
+ let(:supported_project_embeds_table) do
+ supported_project_embeds.map do |embed|
+ "projectLabel:#{embed[0]} | projectValue:#{embed[0]} |
"
+ end
+ end
+ let(:description) do
+ <<~DESCRIPTION
+ ## Project attributes and labels
+ #{supported_project_embeds_table}
+ Custom field boolean |
+ projectValue:"#{project_custom_field_bool.name}"
+ |
+ Custom field rich text |
+ projectValue:"#{project_custom_field_long_text.name}"
+ |
+ Custom field hidden |
+ projectValue:"#{project_custom_field_string.name}"
+ |
+ No replacement of: |
+ projectValue:1:status
+ projectLabel:status
+ |
+
+
+ `projectValue:2:status projectLabel:status`
+
+ ```
+ projectValue:3:status
+ projectLabel:status
+ ```
+
+ Project by identifier:
+ projectValue:"#{project.identifier}":active
+
+ Project not found:
+ projectValue:1234567890:active
+ Access denied:
+ projectValue:#{forbidden_project.id}:active
+ Access denied by identifier:
+ projectValue:"#{forbidden_project.identifier}":active
+ DESCRIPTION
+ end
+ it "contains resolved attributes and labels" do
+ result = pdf[:strings]
+ expected_result = [
+ *expected_details,
+ label_title(:description),
+ "Project attributes and labels",
+ supported_project_embeds.map do |embed|
+ [Project.human_attribute_name(
+ API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: project)
+ ), embed[1]]
+ end,
+ "Custom field boolean", I18n.t(:general_text_Yes),
+ "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]",
+ "Custom field hidden",
+
+ "No replacement of:", "projectValue:1:status", "projectLabel:status",
+ "projectValue:2:status projectLabel:status",
+ "projectValue:3:status", "projectLabel:status",
+
+ "1", export_time_formatted, project.name,
+
+ "Project by identifier:", " ", I18n.t(:general_text_Yes),
+ "Project not found: ",
+ "[#{I18n.t('export.macro.error', message:
+ I18n.t('export.macro.resource_not_found', resource: "Project 1234567890"))}] ",
+ "Access denied: ",
+ "[#{I18n.t('export.macro.error', message:
+ I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}] ",
+ "Access denied by identifier:", " ", "[Macro error, resource not found: Project", "forbidden-project]",
+
+ "2", export_time_formatted, project.name,
+ ].flatten
+ expect(result.join(" ")).to eq(expected_result.join(" "))
end
- # Joining the results for comparison since word wrapping leads to a different array for the same content
- result = pdf[:strings].join(" ")
- expected_result = [
- "#{type.name} ##{work_package.id} - #{work_package.subject}",
- *details,
- label_title(:description),
- "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ",
- "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin",
- "Image Caption",
- "Foo",
- "LongText", "foo",
- "1", export_time_formatted, project.name
- ].join(" ")
- expect(result).to eq(expected_result)
- expect(result).not_to include("DisabledCustomField")
- expect(pdf[:images].length).to eq(2)
end
end
end