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 ![](/api/v3/attachments/#{image_attachment.id}/content) @@ -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