diff --git a/app/models/exports/pdf/common/common.rb b/app/models/exports/pdf/common/common.rb index 5d17e9639708..fed4754959ee 100644 --- a/app/models/exports/pdf/common/common.rb +++ b/app/models/exports/pdf/common/common.rb @@ -428,9 +428,9 @@ def make_link_href_cell(href, caption) "#{make_link_href(href, caption)}" end - def get_id_column_cell(work_package, value) + def get_id_column_cell(work_package) href = url_helpers.work_package_url(work_package) - make_link_href_cell(href, value) + make_link_href_cell(href, work_package.display_id) end def get_subject_column_cell(work_package, value) @@ -469,7 +469,7 @@ def get_cf_link_cell(custom_url) def get_value_cell_by_column(work_package, column_name, format_subject) value = get_column_value(work_package, column_name) return get_cf_link_cell(value) if value.is_a?(::Exports::Formatters::LinkFormatter) - return get_id_column_cell(work_package, value) if column_name == :id + return get_id_column_cell(work_package) if column_name == :id return get_subject_column_cell(work_package, value) if format_subject && column_name == :subject escape_tags(value) diff --git a/app/models/exports/pdf/common/markdown.rb b/app/models/exports/pdf/common/markdown.rb index 6121610f74d0..197ef474e114 100644 --- a/app/models/exports/pdf/common/markdown.rb +++ b/app/models/exports/pdf/common/markdown.rb @@ -35,6 +35,7 @@ class MD2PDFExport include MarkdownToPDF::Core include MarkdownToPDF::Parser include Exports::PDF::Common::Common + include Exports::PDF::Common::WorkPackageMentions def initialize(styling_yml, pdf, hyphenation_language) @styles = MarkdownToPDF::Styles.new(styling_yml) @@ -94,45 +95,14 @@ def handle_wp_mention_html_tag(tag, node, opts) wp_mention_macro(tag.attr("data-text") || "", tag.attr("data-id") || "", opts) end - def expand_wp_mention(work_package, content) - detail_level = content.count("#") - return content if detail_level == 1 - - # ##1234: {Type} #{ID}: {Subject} - content = "#{work_package.type} ##{work_package.id}: #{work_package.subject}" - return content if detail_level == 2 - - # ###1234: {Status} {Type} #{ID}: {Subject} ({Start Date} - {End Date}) - "#{work_package.status.name} #{content}#{work_package_dates(work_package)}" - end - def wp_mention_macro(content, id, opts) - id = id[/\d+/] return [text_hash(content, opts)] if id.blank? - work_package = WorkPackage.find_by(id: id) + work_package = WorkPackage.find_by_display_id(id) return [text_hash(content, opts)] unless work_package&.visible? content = expand_wp_mention(work_package, content) - [text_hash(content, opts.merge({ link: url_helpers.work_package_url(id) }))] - end - - def work_package_dates(work_package) - return "" if work_package.start_date.blank? && work_package.due_date.blank? - - if work_package.due_date.present? && work_package.start_date == work_package.due_date - return " (#{format_date(work_package.due_date)})" - end - - work_package_date_range(work_package) - end - - def work_package_date_range(work_package) - content = [ - work_package.start_date.present? ? format_date(work_package.start_date) : I18n.t("label_no_start_date"), - work_package.due_date.present? ? format_date(work_package.due_date) : I18n.t("label_no_due_date") - ].join(" - ") - " (#{content})" + [text_hash(content, opts.merge({ link: url_helpers.work_package_url(work_package) }))] end def handle_mention_html_tag(tag, node, opts) diff --git a/app/models/exports/pdf/common/work_package_mentions.rb b/app/models/exports/pdf/common/work_package_mentions.rb new file mode 100644 index 000000000000..06db3a0a7b1c --- /dev/null +++ b/app/models/exports/pdf/common/work_package_mentions.rb @@ -0,0 +1,63 @@ +# 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 Exports::PDF::Common::WorkPackageMentions + include Redmine::I18n + + def expand_wp_mention(work_package, content) + detail_level = content.count("#") + return work_package.formatted_id if detail_level == 1 + + # ##: {Type} {formatted_id}: {Subject} + content = "#{work_package.type} #{work_package.formatted_id}: #{work_package.subject}" + return content if detail_level == 2 + + # ###: {Status} {Type} {formatted_id}: {Subject} ({Start Date} - {End Date}) + "#{work_package.status.name} #{content}#{work_package_dates(work_package)}" + end + + def work_package_dates(work_package) + return "" if work_package.start_date.blank? && work_package.due_date.blank? + + if work_package.due_date.present? && work_package.start_date == work_package.due_date + return " (#{format_date(work_package.due_date)})" + end + + work_package_date_range(work_package) + end + + def work_package_date_range(work_package) + content = [ + work_package.start_date.present? ? format_date(work_package.start_date) : I18n.t("label_no_start_date"), + work_package.due_date.present? ? format_date(work_package.due_date) : I18n.t("label_no_due_date") + ].join(" - ") + " (#{content})" + end +end diff --git a/app/models/exports/pdf/components/gantt/gantt_builder.rb b/app/models/exports/pdf/components/gantt/gantt_builder.rb index 1d676164a648..aec81b947f08 100644 --- a/app/models/exports/pdf/components/gantt/gantt_builder.rb +++ b/app/models/exports/pdf/components/gantt/gantt_builder.rb @@ -742,7 +742,7 @@ def build_row_text_lines_wp_info(entry, left, right, top) # @param [WorkPackage] work_package # @return [String] def work_package_info_line(work_package) - "#{work_package.type} ##{work_package.id} • #{work_package.status} • #{work_package_info_line_date work_package}" + "#{work_package.type} #{work_package.formatted_id} • #{work_package.status} • #{work_package_info_line_date work_package}" end def work_package_info_line_date(work_package) diff --git a/app/models/work_package/pdf_export/document_generator.rb b/app/models/work_package/pdf_export/document_generator.rb index 24ff567ad66a..e9bf70d064e8 100644 --- a/app/models/work_package/pdf_export/document_generator.rb +++ b/app/models/work_package/pdf_export/document_generator.rb @@ -83,7 +83,7 @@ def footer_title def title # ____.pdf build_pdf_filename([work_package.project, work_package.type, - "##{work_package.id}", work_package.subject].join("_")) + work_package.display_id, work_package.subject].join("_")) end def with_images? diff --git a/app/models/work_package/pdf_export/generator/generator.rb b/app/models/work_package/pdf_export/generator/generator.rb index aaf20aa824d7..f199aa220316 100644 --- a/app/models/work_package/pdf_export/generator/generator.rb +++ b/app/models/work_package/pdf_export/generator/generator.rb @@ -36,6 +36,7 @@ class MD2PDFGenerator include MarkdownToPDF::Core include MarkdownToPDF::Parser include MarkdownToPDF::StyleSchema + include Exports::PDF::Common::WorkPackageMentions def initialize(styling_yml) symbol_yml = symbolize(styling_yml) @@ -94,17 +95,40 @@ def hyphenate(text) @hyphens.hyphenate(text) end + def handle_wp_mention_html_tag(tag, node, opts) + # #185 + # ##185 + # ###185 + next_node = node&.next + if next_node && next_node.type == :text && next_node.respond_to?(:string_content) + next_node.string_content = "" + end + render_wp_mention(tag.attr("data-text") || "", tag.attr("data-id") || "", opts) + end + + def render_wp_mention(content, id, opts) + return [text_hash(content, opts)] if id.blank? + + work_package = WorkPackage.find_by_display_id(id) + return [text_hash(content, opts)] unless work_package&.visible? + + [text_hash(expand_wp_mention(work_package, content), opts)] + end + def handle_mention_html_tag(tag, node, opts) - if tag.text.blank? - # + if tag.attr("data-type") == "work_package" + handle_wp_mention_html_tag(tag, node, opts) + elsif tag.text.blank? # text = tag.attr("data-text") if text.present? && !node.next.respond_to?(:string_content) && node.next.string_content != text return [text_hash(text, opts)] end + [] + else + # @Some User + [] end - # @Some User - [] end def handle_unknown_inline_html_tag(tag, node, opts) diff --git a/app/models/work_package/pdf_export/work_package_to_pdf.rb b/app/models/work_package/pdf_export/work_package_to_pdf.rb index 541dcb1203a5..ebb0759a769a 100644 --- a/app/models/work_package/pdf_export/work_package_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_to_pdf.rb @@ -108,7 +108,7 @@ def wp_title_formatted_text(work_package, style) end def heading - "#{work_package.type} ##{work_package.id} - #{work_package.subject}" + "#{work_package.type} #{work_package.formatted_id} - #{work_package.subject}" end def footer_title @@ -118,7 +118,7 @@ def footer_title def title # ____.pdf build_pdf_filename([work_package.project, work_package.type, - "##{work_package.id}", work_package.subject].join("_")) + work_package.display_id, work_package.subject].join("_")) end def write_description!(work_package) diff --git a/spec/models/exports/pdf/common/markdown/md2_pdf_export_spec.rb b/spec/models/exports/pdf/common/markdown/md2_pdf_export_spec.rb new file mode 100644 index 000000000000..813c3b584b48 --- /dev/null +++ b/spec/models/exports/pdf/common/markdown/md2_pdf_export_spec.rb @@ -0,0 +1,108 @@ +# 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 Exports::PDF::Common::Markdown::MD2PDFExport do + let(:exporter) { described_class.allocate } + let(:type) { build_stubbed(:type, name: "Bug") } + let(:status) { build_stubbed(:status, name: "In Progress") } + let(:work_package) do + build_stubbed(:work_package, type:, status:, subject: "Fix login") + end + + describe "#wp_mention_macro" do + let(:admin) { create(:admin) } + let(:wp) { create(:work_package) } + + before do + User.current = admin + end + + context "in classic mode", + with_flag: { semantic_work_package_ids: false } do + it "generates a link URL with the numeric id" do + result = exporter.wp_mention_macro("##{wp.id}", wp.id.to_s, {}) + expect(result.first[:link]).to include(wp.id.to_s) + end + end + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + it "generates a link URL with the semantic identifier" do + result = exporter.wp_mention_macro("##{wp.id}", wp.id.to_s, {}) + expect(result.first[:link]).to include(wp.identifier) + end + end + end + + describe "#expand_wp_mention" do + context "in classic mode", with_settings: { work_packages_identifier: "classic" } do + it "returns formatted_id for level 1" do + expect(exporter.expand_wp_mention(work_package, "##{work_package.id}")) + .to eq("##{work_package.id}") + end + + it "uses formatted_id in level 2 expansion" do + result = exporter.expand_wp_mention(work_package, "###{work_package.id}") + expect(result).to eq("Bug ##{work_package.id}: Fix login") + end + + it "uses formatted_id in level 3 expansion" do + allow(exporter).to receive(:work_package_dates).with(work_package).and_return("") + result = exporter.expand_wp_mention(work_package, "####{work_package.id}") + expect(result).to eq("In Progress Bug ##{work_package.id}: Fix login") + end + end + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + before { work_package.identifier = "PROJ-42" } + + it "returns semantic formatted_id for level 1" do + expect(exporter.expand_wp_mention(work_package, "##{work_package.id}")) + .to eq("PROJ-42") + end + + it "uses semantic formatted_id in level 2 expansion" do + result = exporter.expand_wp_mention(work_package, "###{work_package.id}") + expect(result).to eq("Bug PROJ-42: Fix login") + end + + it "uses semantic formatted_id in level 3 expansion" do + allow(exporter).to receive(:work_package_dates).with(work_package).and_return("") + result = exporter.expand_wp_mention(work_package, "####{work_package.id}") + expect(result).to eq("In Progress Bug PROJ-42: Fix login") + end + end + end +end diff --git a/spec/models/work_package/pdf_export/document_generator_spec.rb b/spec/models/work_package/pdf_export/document_generator_spec.rb index 99d6d586f595..72fb8d91af4a 100644 --- a/spec/models/work_package/pdf_export/document_generator_spec.rb +++ b/spec/models/work_package/pdf_export/document_generator_spec.rb @@ -7,7 +7,7 @@ include Redmine::I18n include PDFExportSpecUtils - let(:project) { create(:project) } + let(:project) { create(:project, name: "PDF Project") } let(:user) { create(:admin) } let(:description) do "This is a test description with an macro: workPackageValue:assignee" @@ -20,7 +20,7 @@ subject: "Document Generator Specs", type:) end - let(:type) { create(:type) } + let(:type) { create(:type, name: "Feature") } let(:options) do {} end @@ -52,6 +52,25 @@ PDF::Inspector::Text.analyze(content).strings end + describe "#title" do + context "in classic mode", + with_flag: { semantic_work_package_ids: false } do + it "uses the numeric id in the filename" do + expected_title = "PDF_Project_Feature_#{work_package.id}_Document_Generator_Specs_2023-06-30_23-59.pdf" + expect(export_pdf.title).to eql(expected_title) + end + end + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + it "uses the semantic identifier in the filename" do + expected_title = "PDF_Project_Feature_#{work_package.identifier}_Document_Generator_Specs_2023-06-30_23-59.pdf" + expect(export_pdf.title).to eql(expected_title) + end + end + end + describe "with a request for a PDF" do it "contains correct data" do expected_result = [ @@ -65,6 +84,15 @@ expect(result.join(" ")).to eq(expected_result.join(" ")) end + describe "with a work package mention in the description" do + let(:other_work_package) { create(:work_package, project:, type:) } + let(:description) { "##{other_work_package.id}" } + + it "renders the mention using formatted_id" do + expect(pdf).to include(other_work_package.formatted_id) + end + end + describe "with a request for a PDF with hyphenation and no header/footer text" do let(:options) do { diff --git a/spec/models/work_packages/pdf_export/work_package_list_to_pdf_gantt_spec.rb b/spec/models/work_packages/pdf_export/work_package_list_to_pdf_gantt_spec.rb index 01b34feb97f0..456a94402b21 100644 --- a/spec/models/work_packages/pdf_export/work_package_list_to_pdf_gantt_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_list_to_pdf_gantt_spec.rb @@ -177,7 +177,8 @@ def wp_title_dates(work_package) end def wp_title_column(work_package) - "#{work_package.type} ##{work_package.id} • #{work_package.status} • #{wp_title_dates work_package} #{work_package.subject}" + "#{work_package.type} #{work_package.formatted_id} • #{work_package.status} • " \ + "#{wp_title_dates work_package} #{work_package.subject}" end subject(:pdf) do @@ -225,6 +226,24 @@ def include_calls?(calls_to_find, all_calls) end end + describe "work package id formatting" do + context "in classic mode", + with_flag: { semantic_work_package_ids: false } do + it "uses the numeric id and not the semantic identifier in the gantt chart" do + expect(pdf[:strings]).to include("##{work_package_task.id}") + end + end + + context "in semantic mode", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + it "uses the semantic identifier and not the numeric id in the gantt chart" do + expect(pdf[:strings]).to include(work_package_task.identifier) + expect(pdf[:strings]).not_to include("##{work_package_task.id}") + end + end + end + describe "with a request for a PDF gantt split on multiple horizontal pages" do let(:work_package_milestone_due) do Date.new(2024, 5, 8) diff --git a/spec/models/work_packages/pdf_export/work_package_list_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_list_to_pdf_spec.rb index 9d09273d68e9..361e9d8167f7 100644 --- a/spec/models/work_packages/pdf_export/work_package_list_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_list_to_pdf_spec.rb @@ -132,7 +132,7 @@ def work_packages_sum def work_package_columns(work_package) [ - work_package.id.to_s, + work_package.display_id.to_s, work_package.subject, work_package.status.name, work_package.story_points.to_s, @@ -148,7 +148,7 @@ def work_package_done_ratio(work_package) def work_package_details(work_package, index, ltfs = []) result = [ "#{index}.", work_package.subject, - column_title(:id), work_package.id.to_s, + column_title(:id), work_package.display_id.to_s, column_title(:status), work_package.status.name, column_title(:story_points), work_package.story_points.to_s, column_title(:done_ratio), work_package_done_ratio(work_package), @@ -250,12 +250,12 @@ def pdf_strings_without_footers(nr_of_pages) project_phase_with_gates.name, *column_titles - ["Project phase"], - work_package_parent.id.to_s, + work_package_parent.display_id.to_s, work_package_parent.subject, project_phase.name, *column_titles - ["Project phase"], - work_package_child.id.to_s, + work_package_child.display_id.to_s, work_package_child.subject ] strings = pdf_strings_without_footers(1) @@ -453,7 +453,7 @@ def pdf_strings_without_footers(nr_of_pages) def relation_table_row(work_package) [ - work_package.id.to_s, + work_package.display_id.to_s, work_package.type.name, work_package.subject, work_package.status.name @@ -463,7 +463,7 @@ def relation_table_row(work_package) def detail_attributes(work_package, index) [ "#{index}.", work_package.subject, - column_title(:id), work_package.id.to_s, + column_title(:id), work_package.display_id.to_s, column_title(:status), work_package.status.name ] end diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index ce1184952704..3e57cfa4c908 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -235,7 +235,7 @@ end let(:expected_details) do [ - "#{type.name} ##{work_package.id} - #{work_package.subject}", + "#{type.name} #{work_package.formatted_id} - #{work_package.subject}", " ", exporter.prawn_badge_text_stuffing(work_package.status.name.downcase), # badge & padding "People", "Assignee", user.name,