diff --git a/app/components/admin/import/jira/import_runs/select_projects/list_header_component.html.erb b/app/components/admin/import/jira/import_runs/select_projects/list_header_component.html.erb index 2edd10c1330..b2fce231c38 100644 --- a/app/components/admin/import/jira/import_runs/select_projects/list_header_component.html.erb +++ b/app/components/admin/import/jira/import_runs/select_projects/list_header_component.html.erb @@ -21,7 +21,7 @@ scheme: :invisible, tag: :a, href: check_all_url, - data: { turbo_stream: true } + data: { action: "click->admin--jira-projects#checkAll" } ) ) do |button| button.with_leading_visual_icon(icon: :"check-circle") @@ -34,7 +34,7 @@ scheme: :invisible, tag: :a, href: uncheck_all_url, - data: { turbo_stream: true } + data: { action: "click->admin--jira-projects#uncheckAll" } ) ) do |button| button.with_leading_visual_icon(icon: :"x-circle") diff --git a/app/components/admin/import/jira/import_runs/select_projects/modal_component.html.erb b/app/components/admin/import/jira/import_runs/select_projects/modal_component.html.erb index fcadac7f789..bbc026ef1db 100644 --- a/app/components/admin/import/jira/import_runs/select_projects/modal_component.html.erb +++ b/app/components/admin/import/jira/import_runs/select_projects/modal_component.html.erb @@ -39,11 +39,24 @@ I18n.t(:button_cancel) end modal_footer.with_component( - Admin::Import::Jira::ImportRuns::SelectProjects::ModalSubmitComponent.new( - jira_import: @jira_import, - count: selected_count - ) + content_tag(:div, data: { "admin--jira-projects-target": "submitButton" }) { + render(Admin::Import::Jira::ImportRuns::SelectProjects::ModalSubmitComponent.new( + jira_import: @jira_import, + count: selected_count + )) + } ) + modal_footer.with_component( + Primer::Beta::Button.new( + scheme: :primary, + tag: :a, + hidden: true, + data: { "admin--jira-projects-target": "spinnerButton" } + ) + ) do |spinner_button| + spinner_button.with_trailing_visual_icon(icon: :sync, animation: :rotate, style: "min-width: 2rem") + I18n.t(:button_continue) + end end end end diff --git a/app/components/admin/import/jira/import_runs/select_projects/modal_submit_component.html.erb b/app/components/admin/import/jira/import_runs/select_projects/modal_submit_component.html.erb index 082135deb7e..6d307a2159f 100644 --- a/app/components/admin/import/jira/import_runs/select_projects/modal_submit_component.html.erb +++ b/app/components/admin/import/jira/import_runs/select_projects/modal_submit_component.html.erb @@ -10,7 +10,7 @@ } ) ) do |button| - button.with_trailing_visual_counter(count: count) + button.with_trailing_visual_counter(count: count, style: "min-width: 2rem") I18n.t(:button_continue) end end %> diff --git a/app/components/work_package_types/form_configuration/main_content_component.html.erb b/app/components/work_package_types/form_configuration/main_content_component.html.erb index b54e4e5f80d..ee3580e0774 100644 --- a/app/components/work_package_types/form_configuration/main_content_component.html.erb +++ b/app/components/work_package_types/form_configuration/main_content_component.html.erb @@ -1,7 +1,6 @@ <%= component_wrapper( - class: "type-form-configuration-page--main-inner", - data: main_inner_data + class: "type-form-configuration-page--main-inner" ) do flex_layout do |main| main.with_row do diff --git a/app/components/work_package_types/form_configuration/main_content_component.rb b/app/components/work_package_types/form_configuration/main_content_component.rb index bae22743782..6313c210f96 100644 --- a/app/components/work_package_types/form_configuration/main_content_component.rb +++ b/app/components/work_package_types/form_configuration/main_content_component.rb @@ -47,13 +47,6 @@ module WorkPackageTypes @ee_available end - def main_inner_data - { - controller: "admin--type-form-configuration--drag-and-drop", - "admin--type-form-configuration--drag-and-drop-handle-selector-value": ".group-handle" - } - end - def groups_container_data { "test-selector": "type-form-configuration-groups-container", diff --git a/app/components/work_package_types/form_configuration_component.html.erb b/app/components/work_package_types/form_configuration_component.html.erb index 4fd270636f4..22562fe20f2 100644 --- a/app/components/work_package_types/form_configuration_component.html.erb +++ b/app/components/work_package_types/form_configuration_component.html.erb @@ -42,7 +42,10 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% content.with_main(classes: "type-form-configuration-page--main") do %> -
+ <%= tag.div( + class: "type-form-configuration-page--active-list", + data: active_list_data + ) do %> <%= render(EnterpriseEdition::BannerComponent.new(:edit_attribute_groups, mb: 3)) %> <% unless ee_available? %> @@ -58,7 +61,7 @@ See COPYRIGHT and LICENSE files for more details. ee_available: ee_available? ) ) %> -
+ <% end %> <% end %> <% end %> <% end %> diff --git a/app/components/work_package_types/form_configuration_component.rb b/app/components/work_package_types/form_configuration_component.rb index 1d55de92325..42e0bc29a51 100644 --- a/app/components/work_package_types/form_configuration_component.rb +++ b/app/components/work_package_types/form_configuration_component.rb @@ -55,6 +55,15 @@ module WorkPackageTypes } end + def active_list_data + { + controller: "admin--type-form-configuration--drag-and-drop", + "admin--type-form-configuration--drag-and-drop-handle-selector-value": ".group-handle", + "admin--type-form-configuration--drag-and-drop-target": "scrollContainer", + "admin--type-form-configuration--rows-drag-and-drop-target": "scrollContainer" + } + end + def group_components @groups.map.with_index do |group, i| WorkPackageTypes::FormConfiguration::GroupComponent.new( diff --git a/app/components/work_packages/admin/settings/identifier_autofix_row_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_row_component.rb new file mode 100644 index 00000000000..2cca49038bb --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_autofix_row_component.rb @@ -0,0 +1,70 @@ +# 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 WorkPackages + module Admin + module Settings + class IdentifierAutofixRowComponent < OpPrimer::BorderBoxRowComponent + def project + render(Primer::Beta::Link.new(href: project_path(model[:project]))) { model[:project].name } + end + + def previous_identifier + flex_layout(direction: :column) do |col| + col.with_row { render(Primer::Beta::Text.new) { model[:current_identifier] } } + if (label = error_label).present? + col.with_row do + render(Primer::OpenProject::InlineMessage.new(scheme: :critical, size: :small)) { label } + end + end + end + end + + def autofixed_suggestion + model[:suggested_identifier] + end + + # The sequence number is derived deterministically from the identifier so it looks + # varied across projects but is stable across renders. Range: 1–500. + def example_work_package_id + identifier = model[:suggested_identifier] + "#{identifier}-#{(identifier.bytes.sum % 500) + 1}" + end + + private + + def error_label + I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_#{model[:error_reason]}", + default: "") + end + end + end + end +end diff --git a/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb index a9ba02a0445..3386b800f4c 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.html.erb @@ -38,72 +38,11 @@ end %> -<%= - render(border_box_container(mb: 3)) do |component| - component.with_header(font_weight: :bold) do - flex_layout do |header| - header.with_column(flex: 1) do - render(Primer::Beta::Text.new(font_weight: :semibold)) do - I18n.t("admin.settings.work_packages_identifier.box_header.label_project") - end - end - header.with_column(flex: 1) do - render(Primer::Beta::Text.new(font_weight: :semibold)) do - I18n.t("admin.settings.work_packages_identifier.box_header.label_previous_identifier") - end - end - header.with_column(flex: 1) do - render(Primer::Beta::Text.new(font_weight: :semibold)) do - I18n.t("admin.settings.work_packages_identifier.box_header.label_autofixed_suggestion") - end - end - header.with_column(flex: 1) do - render(Primer::Beta::Text.new(font_weight: :semibold)) do - I18n.t("admin.settings.work_packages_identifier.box_header.label_example_work_package_id") - end - end - end - end - - displayed.each do |entry| - component.with_row do - flex_layout(align_items: :center) do |row| - row.with_column(flex: 1) do - render(Primer::Beta::Link.new(href: project_path(entry[:project]))) do - entry[:project].name - end - end - row.with_column(flex: 1) do - flex_layout(direction: :column) do |col| - col.with_row do - render(Primer::Beta::Text.new) { entry[:current_identifier] } - end - col.with_row do - render(Primer::Beta::Text.new(color: :danger, font_size: :small)) do - error_label(entry[:error_reason]) - end - end - end - end - row.with_column(flex: 1) do - render(Primer::Beta::Text.new) { entry[:suggested_identifier] } - end - row.with_column(flex: 1) do - render(Primer::Beta::Text.new) { sample_wp_id(entry[:suggested_identifier]) } - end - end - end - end - - if remaining_count.positive? - component.with_row do - render(Primer::Beta::Text.new(color: :muted)) do - I18n.t( - "admin.settings.work_packages_identifier.autofix_preview.remaining_projects", - count: remaining_count - ) - end - end - end - end -%> +<%= render(Primer::Box.new(mb: 3)) do %> + <%= render( + WorkPackages::Admin::Settings::IdentifierAutofixTableComponent.new( + rows: displayed, + remaining_count: + ) + ) %> +<% end %> diff --git a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb index 7ca1d1acc62..8dbeea1b6d6 100644 --- a/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb +++ b/app/components/work_packages/admin/settings/identifier_autofix_section_component.rb @@ -32,8 +32,6 @@ module WorkPackages module Admin module Settings class IdentifierAutofixSectionComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - DISPLAY_COUNT = ProjectIdentifiers::IdentifierAutofix::PreviewQuery::DISPLAY_COUNT def initialize(projects_data:, total_count: projects_data.size) @@ -46,19 +44,6 @@ module WorkPackages private attr_reader :total_count, :displayed, :remaining_count - - def error_label(error_reason) - I18n.t("admin.settings.work_packages_identifier.autofix_preview.error_#{error_reason}", - default: "") - end - - # Produces a realistic-looking example work package ID for the preview table. - # The sequence number is derived deterministically from the identifier so it looks - # varied across projects but is stable across renders. Range: 1–500. - def sample_wp_id(identifier) - n = (identifier.bytes.sum % 500) + 1 - "#{identifier}-#{n}" - end end end end diff --git a/app/components/work_packages/admin/settings/identifier_autofix_table_component.rb b/app/components/work_packages/admin/settings/identifier_autofix_table_component.rb new file mode 100644 index 00000000000..d912f24f5ec --- /dev/null +++ b/app/components/work_packages/admin/settings/identifier_autofix_table_component.rb @@ -0,0 +1,80 @@ +# 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 WorkPackages + module Admin + module Settings + class IdentifierAutofixTableComponent < OpPrimer::BorderBoxTableComponent + columns :project, :previous_identifier, :autofixed_suggestion, :example_work_package_id + # Project and previous identifier hold the long content; spanning two grid columns lets + # them wrap instead of truncating, while the short handle columns stay compact. + main_column :project, :previous_identifier + mobile_labels :previous_identifier, :autofixed_suggestion, :example_work_package_id + + def initialize(rows:, remaining_count: 0, **) + super(rows:, **) + @remaining_count = remaining_count + end + + def row_class + IdentifierAutofixRowComponent + end + + def mobile_title + header(:table_title) + end + + def headers + [ + [:project, { caption: header(:label_project) }], + [:previous_identifier, { caption: header(:label_previous_identifier) }], + [:autofixed_suggestion, { caption: header(:label_autofixed_suggestion) }], + [:example_work_package_id, { caption: header(:label_example_work_package_id) }] + ] + end + + def has_footer? + @remaining_count.positive? + end + + def footer + I18n.t("admin.settings.work_packages_identifier.autofix_preview.remaining_projects", + count: @remaining_count) + end + + private + + def header(key) + I18n.t("admin.settings.work_packages_identifier.box_header.#{key}") + end + end + end + end +end diff --git a/app/controllers/concerns/work_packages/with_split_view.rb b/app/controllers/concerns/work_packages/with_split_view.rb index d9548d45310..db35f854deb 100644 --- a/app/controllers/concerns/work_packages/with_split_view.rb +++ b/app/controllers/concerns/work_packages/with_split_view.rb @@ -35,9 +35,5 @@ module WorkPackages included do helper_method :split_view_base_route end - - def split_view_work_package_id - params[:work_package_id].to_i - end end end diff --git a/app/controllers/work_packages/bulk_controller.rb b/app/controllers/work_packages/bulk_controller.rb index 2393cff6737..44a3627701f 100644 --- a/app/controllers/work_packages/bulk_controller.rb +++ b/app/controllers/work_packages/bulk_controller.rb @@ -130,9 +130,20 @@ class WorkPackages::BulkController < ApplicationController attributes = permitted_params.update_work_package attributes[:custom_field_values] = transform_attributes(attributes[:custom_field_values]) + attributes = attributes_with_normalized_parent_id(attributes) transform_attributes(attributes) end + def attributes_with_normalized_parent_id(attributes) + raw = attributes[:parent_id] + return attributes unless WorkPackage::SemanticIdentifier.semantic_id?(raw.to_s) + + wp = WorkPackage.find_by_display_id(raw) + # If the semantic ID hasn't resolved to a proper package, default to 0, which is an invalid value + # that will trigger errors in the main update service + attributes.merge(parent_id: wp ? wp.id : 0) + end + def user current_user end diff --git a/app/mailers/sharing_mailer.rb b/app/mailers/sharing_mailer.rb index af5f409d15c..ca9dbbb9df2 100644 --- a/app/mailers/sharing_mailer.rb +++ b/app/mailers/sharing_mailer.rb @@ -40,8 +40,7 @@ class SharingMailer < ApplicationMailer @work_package = membership.entity role = membership.roles.first - @url = optionally_activated_url(work_package_url(@work_package.id), @invitation_token) - @notification_url = optionally_activated_url(details_notifications_url(@work_package.id, tab: :activity), @invitation_token) + @url = optionally_activated_url(work_package_url(@work_package), @invitation_token) set_open_project_headers(@work_package) message_id(membership, sharer) diff --git a/app/views/mailer/_notification_row.html.erb b/app/views/mailer/_notification_row.html.erb index 66ba12de800..f61719552f1 100644 --- a/app/views/mailer/_notification_row.html.erb +++ b/app/views/mailer/_notification_row.html.erb @@ -1,5 +1,5 @@ <%= render layout: "mailer/border_table" do %> @@ -105,7 +105,7 @@ <%= I18n.t("mail.work_packages.open_in_browser") %> diff --git a/app/views/sharing_mailer/shared_work_package.text.erb b/app/views/sharing_mailer/shared_work_package.text.erb index 4e48dd1f14a..e5f9d2cdb84 100644 --- a/app/views/sharing_mailer/shared_work_package.text.erb +++ b/app/views/sharing_mailer/shared_work_package.text.erb @@ -18,9 +18,9 @@ %> <%= "-" * 100 %> -<%= "=" * (("# " + @work_package.id.to_s + @work_package.subject).length + 4) %> -= #<%= @work_package.id %> <%= @work_package.subject %> = -<%= "=" * (("# " + @work_package.id.to_s + @work_package.subject).length + 4) %> +<%= "=" * ("#{@work_package.formatted_id} #{@work_package.subject}".length + 4) %> += <%= @work_package.formatted_id %> <%= @work_package.subject %> = +<%= "=" * ("#{@work_package.formatted_id} #{@work_package.subject}".length + 4) %> <%= I18n.t("mail.work_packages.reason.shared") %>: <%= t("mail.sharing.work_packages.allowed_actions_html", allowed_actions: @allowed_work_package_actions.to_sentence) %> diff --git a/app/views/work_package_mailer/mentioned.html.erb b/app/views/work_package_mailer/mentioned.html.erb index a7b6d914862..ce5cf8322d1 100644 --- a/app/views/work_package_mailer/mentioned.html.erb +++ b/app/views/work_package_mailer/mentioned.html.erb @@ -11,7 +11,7 @@ <%= render partial: "mailer/mailer_header", locals: { summary: I18n.t(:"mail.work_packages.mentioned_by", user: @journal.user), - button_href: details_notifications_url(@work_package.id, tab: :activity), + button_href: details_notifications_url(@work_package, tab: :activity), button_text: I18n.t(:"mail.notification.see_in_center"), user: @user } %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 28ed13fa8a2..1fbb87c1bf7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -453,6 +453,7 @@ en: OpenProject can automatically update these so that they are valid as in the examples below. Click on 'Convert identifiers' to update identifiers for all projects in this manner and enable project-based semantic identifiers. box_header: + table_title: Projects with identifiers to update label_project: Project label_previous_identifier: Previous identifier label_autofixed_suggestion: Future identifier diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/README.md b/docs/system-admin-guide/manage-work-packages/work-package-workflows/README.md index 737530f239f..f79ab1583bb 100644 --- a/docs/system-admin-guide/manage-work-packages/work-package-workflows/README.md +++ b/docs/system-admin-guide/manage-work-packages/work-package-workflows/README.md @@ -17,7 +17,7 @@ This means, a certain type of work package, e.g. a Task, can have the following To edit a workflow, navigate to *Administration → Work packages → Workflows*. You will see an overview of all available work package types. -![List of work packages types under Workflows editing in OpenProject administration](openproject_system_guide_wp_workflows_list.png) +![List of work packages types under Workflows editing in OpenProject administration](openproject_system_guide_wp_workflows_menu.png) Select the type of work package for which you want to edit the workflow, e.g. *Task*. @@ -25,9 +25,15 @@ Once opened, you can configure workflows for this type: 1. Choose whether you want to edit default transitions, or transitions when a user is the **author** or **assignee** using the tabs at the top of the page. -![Tabs to select between default transitions, when the user is the author or when the user is the assignee](admin_workflow_tabs.png) + ![Menu list of work packages types under Workflows editing in OpenProject administration](openproject_system_guide_wp_workflows_menu_edit.png) -2. Select the **role** for which you want to configure the workflow. The workflow table will update automatically when switching roles. + + +![Tabs to select between default transitions, when the user is the author or when the user is the assignee](openproject_system_guide_wp_workflows_role_list.png) + +2. Select the **role** or **roles** for which you want to configure the workflow from the select panel. The workflow table will update automatically when switching roles. The role panel will also update to reflect the selected number of roles. When multiple roles are selected, the checkboxes of the workflow table assign transitions for all. When only some of the selected roles have the transition, the checkboxes are marked as partial. + + ![Panel to select roles for a work package type in default transitions](openproject_system_guide_wp_workflows_select_role.png) 3. Define which **statuses** are available for this type: - Click **+ Status** to add or remove statuses. @@ -66,15 +72,14 @@ You will then be able to select which existing workflow should be copied to sele -![Example for copying a work package workflow in OpenProject administration](System-admin-guide-work-package-workflows_copy_type.png) - - +![Example for copying a work package workflow from one type to another in OpenProject administration](System-admin-guide-work-package-workflows_copy_type.png) You can also copy to other roles by selecting a role or multiple target roles from the drop-down list. +![Example for copying a work package workflow to other roles in OpenProject administration](System-admin-guide-work-package-workflows_copy_to_roles.png) - -![Example for copying a work package workflow in OpenProject administration](System-admin-guide-work-package-workflows_copy_to_roles.png) +![Example for copying a work package workflow in OpenProject administration,copy button highlighted]( +System-admin-guide-work-package-workflows_copy_to_roles_save.png) You can also choose to use the workflows for the source type and role as the blueprint for multiple target types at the same time. @@ -84,11 +89,11 @@ The copy of a workflow can later on be altered to better reflect the desired tra You can get a summary of the allowed status transitions of a work package type for a role by clicking on **Summary** in the workflow overview. -![Summary of work package workflows in OpenProject administration](System-admin-guide-work-package-workflows_summary.png) +![Summary of work package workflows in OpenProject administration](System-admin-guide-work-package-workflows_overview.png) You will then view a summary of all the workflows. The number of possible status transitions for each type and role are shown in a matrix. -![Overview of work package workflow summary in OpenProject administration](System-admin-guide-work-package-workflows_overview.png) +![Overview of work package workflow summary in OpenProject administration](System-admin-guide-work-package-workflows_summary.png) > [!TIP] > For more examples on using workflows in OpenProject take a look at [this blog article](https://www.openproject.org/blog/status-and-workflows/). diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy.png index c25f5aa7c74..c35d982baa0 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_to_roles.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_to_roles.png index 9b064960dfc..4d4b19cbd80 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_to_roles.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_to_roles.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_to_roles_save.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_to_roles_save.png new file mode 100644 index 00000000000..16d7ec679b7 Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_to_roles_save.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_type.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_type.png index b2872296ca6..454df2c256e 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_type.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_copy_type.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_overview.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_overview.png index 3cb6329ac0e..c876ddd85c4 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_overview.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_overview.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_summary.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_summary.png index 0670a224e0d..2f9ce13e40d 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_summary.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/System-admin-guide-work-package-workflows_summary.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu.png new file mode 100644 index 00000000000..3d3df26ddf6 Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_copy.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_copy.png new file mode 100644 index 00000000000..601009ae227 Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_copy.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_edit.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_edit.png new file mode 100644 index 00000000000..503f9699908 Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_edit.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_list.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_list.png new file mode 100644 index 00000000000..4ce1b4b83b4 Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_menu_list.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_not_configured.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_not_configured.png index 5d95d2f1c1e..29d11aad016 100644 Binary files a/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_not_configured.png and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_not_configured.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_role_list.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_role_list.png new file mode 100644 index 00000000000..af7aba01c2a Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_role_list.png differ diff --git a/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_select_role.png b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_select_role.png new file mode 100644 index 00000000000..34be0e68c7d Binary files /dev/null and b/docs/system-admin-guide/manage-work-packages/work-package-workflows/openproject_system_guide_wp_workflows_select_role.png differ diff --git a/docs/user-guide/wiki/README.md b/docs/user-guide/wiki/README.md index 42d05c2aaf0..e252f3347f5 100644 --- a/docs/user-guide/wiki/README.md +++ b/docs/user-guide/wiki/README.md @@ -158,4 +158,7 @@ Wiki systems contain all the answers !#42 ### Autocompletion for work packages -For work packages, typing # will open an autocompleter for visible work packages. This means when you type e.g. #3 or #Market, a list of work packages matching the description will be shown. Then you can either continue typing or choose a work package from the list. +For work packages, typing `#` will open an autocompleter for visible work packages. This means when you type e.g. #3 or #Market, a list of work packages matching the description will be shown. Then you can either continue typing or choose a work package from the list. + +> [!TIP] +> To show more details when linking a work package, type either `##` or `###` followed by the work package ID, subject, type or a keyword. diff --git a/docs/user-guide/work-packages/edit-work-package/README.md b/docs/user-guide/work-packages/edit-work-package/README.md index 124d18932d6..8c243ee4d34 100644 --- a/docs/user-guide/work-packages/edit-work-package/README.md +++ b/docs/user-guide/work-packages/edit-work-package/README.md @@ -116,6 +116,9 @@ Starting with OpenProject 13.0 you can add emojis to all text editors. Type a co ![openproject_user_guide_wp_comment_emojis](openproject_user_guide_wp_comment_emojis.png) +### Linking work packages +To learn more about how to link work packages within the text editor, please consult this page [Rich text editor in OpenProject](../../wysiwyg/). + ## Attach files to work packages > [!IMPORTANT] diff --git a/docs/user-guide/wysiwyg/README.md b/docs/user-guide/wysiwyg/README.md index 5046bc9e1c8..3c034bc5768 100644 --- a/docs/user-guide/wysiwyg/README.md +++ b/docs/user-guide/wysiwyg/README.md @@ -35,13 +35,13 @@ Instead of creating a new paragraph with Enter, you can also press `SHIFT+Enter` Create hyperlinks by pressing the tool-bar (optionally with some selected text), or by pressing `CTRL+k` to open a popup to enter the link here. -### Widgets and Newlines +### Widgets and newlines CKEditor uses widgets to display block elements such as images, tables, and other elements that are not inline. You can select most widgets by pressing on them - The only exception to that is the table widget, it has a little select knob at the top left to select the entire table. When you have a widget selected, you can remove or cut it. You can create a newline below it by selecting the widget and pressing `ENTER` or `↓ (ARROW DOWN)`, or a newline above it by pressing `SHIFT+enter` or `↑ (ARROW UP)`. This is especially needed when the widget is the first or last element on the page to insert a line below or above it. -### Code Blocks +### Code blocks As CKEditor5 currently does not provide support for code blocks, OpenProject can display, but not edit code blocks within the CKEditor instance. A code block can be edited through a modal window within a `CodeMirror` editor instance. This has the advantage of providing syntax highlighting and code sensing ([for supported languages](https://codemirror.net/mode/)). @@ -85,7 +85,7 @@ On top of that, OpenProject adds the following shortcut: OpenProject has supported macros on textile formatted pages and continues to do so with the WYSIWYG editor. Note that macros are not expanded while editing the page, instead, a placeholder is shown. You can find the macros here in the text editor: -![Macros text editor](image-20201109183018255.png) +![Macros text editor](openproject_user_guide_macros.png) ### Table of contents @@ -113,24 +113,24 @@ Use it to embed views in other pages, create reporting of multiple results, or t As with the textile formatting syntax, you can link to other resources within OpenProject using the same shortcuts as before. Create links to a: -| **Link target** | Usage example | -|---------------------------------------------------------------|-----------------------------------------------| -| Wiki page | `[[Wiki page]]` | -| Wiki page with separate link name | `[[Wiki page\|The text of the link]]` | -| Wiki page in the Sandbox project | `[[Sandbox:Wiki page]]` | -| Work package with ID12 | `#12` | -| Work package with ID 12 with subject and type | `##12` | -| Work package with ID 12 with subject, type, status, and dates | `###12` | -| Version by ID or name | `version#3`, `version:"Release 1.0.0"` | -| Project by ID/name | `project#12` , `project:"My project name"` | -| Attachment by filename | `attachment:filename.zip` | -| Meeting by ID/name | `meeting#12` , `meeting:"My meeting name"` | -| Document by ID/name | `document#12` , `document:"My document name"` | -| User by ID or login | `user#4` , `user:"johndoe"` | -| Forum message by ID | `message#1218` | -| Repository revision 43 | `r43` | -| Commit by hash | `commit:f30e13e4` | -| Source file in the repository | `source:"some/file"` | +| **Link target** | Usage example | +| ----------------------------------------------------- | --------------------------------------------- | +| Wiki page | `[[Wiki page]]` | +| Wiki page with separate link name | `[[Wiki page\|The text of the link]]` | +| Wiki page in the Sandbox project | `[[Sandbox:Wiki page]]` | +| Work package with ID12 | `#12` | +| Work package with ID 12 with subject and type | `##12` | +| Work package with ID 12 with subject, type and status | `###12` | +| Version by ID or name | `version#3`, `version:"Release 1.0.0"` | +| Project by ID/name | `project#12` , `project:"My project name"` | +| Attachment by filename | `attachment:filename.zip` | +| Meeting by ID/name | `meeting#12` , `meeting:"My meeting name"` | +| Document by ID/name | `document#12` , `document:"My document name"` | +| User by ID or login | `user#4` , `user:"johndoe"` | +| Forum message by ID | `message#1218` | +| Repository revision 43 | `r43` | +| Commit by hash | `commit:f30e13e4` | +| Source file in the repository | `source:"some/file"` | To avoid processing these items, preceding them with a bang `!` character such as `!#12` will prevent linking to a work package with ID 12. @@ -144,6 +144,12 @@ To avoid processing these items, preceding them with a bang `!` character such a For work packages and users, typing `#` or `@` will open an autocomplete dropdown for visible work packages and users, respectively. +![Autocomplete dropdown for visible work packages](openproject_user_guide_macros_autocompletion.png) +![Link a work package using an hash](openproject_user_guide_workpackage_mentions.png) + +> [!TIP] +> To show more details when linking a work package, type either `##` or `###` followed by the work package ID, subject, type or a keyword. + ## Embedding of work package attributes and project attributes > [!NOTE] @@ -172,7 +178,7 @@ Example: **Linking to the assignee of work package with subject "Project start"**: `workPackageValue:"Project start":assignee` -> [!NOTE] +> [!IMPORTANT] > Referencing a work package by subject results in only looking for work packages with that given subject in the current project (if any). > If you need to cross-reference work packages, use their ID to pinpoint the work package you want to reference. > We recommend against using subjects as references, as they are not updated when the referenced subject changes. diff --git a/docs/user-guide/wysiwyg/openproject_user_guide_macros.png b/docs/user-guide/wysiwyg/openproject_user_guide_macros.png new file mode 100644 index 00000000000..1ab45141d86 Binary files /dev/null and b/docs/user-guide/wysiwyg/openproject_user_guide_macros.png differ diff --git a/docs/user-guide/wysiwyg/openproject_user_guide_macros_autocompletion.png b/docs/user-guide/wysiwyg/openproject_user_guide_macros_autocompletion.png new file mode 100644 index 00000000000..7746519beb0 Binary files /dev/null and b/docs/user-guide/wysiwyg/openproject_user_guide_macros_autocompletion.png differ diff --git a/docs/user-guide/wysiwyg/openproject_user_guide_workpackage_mentions.png b/docs/user-guide/wysiwyg/openproject_user_guide_workpackage_mentions.png new file mode 100644 index 00000000000..faa28d7fdc8 Binary files /dev/null and b/docs/user-guide/wysiwyg/openproject_user_guide_workpackage_mentions.png differ diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts index 14c303257d9..50db1cf2fcf 100644 --- a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts @@ -20,6 +20,7 @@ import { } from '@fullcalendar/core'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; import { TimeEntryResource } from 'core-app/features/hal/resources/time-entry-resource'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; import interactionPlugin from '@fullcalendar/interaction'; import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; @@ -620,7 +621,10 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { private entityName(entry:TimeEntryResource):string { const entity = entry.entity; - return `#${idFromLink(entity.href)}: ${entity.name}`; + const formattedId = entity instanceof WorkPackageResource + ? entity.formattedId + : `#${idFromLink(entity.href)}`; + return `${formattedId}: ${entity.name}`; } private popoverHtml( diff --git a/frontend/src/app/shared/components/grids/widgets/time-entries/list/time-entries-list.component.ts b/frontend/src/app/shared/components/grids/widgets/time-entries/list/time-entries-list.component.ts index 6e49b928188..227fd3443b8 100644 --- a/frontend/src/app/shared/components/grids/widgets/time-entries/list/time-entries-list.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/time-entries/list/time-entries-list.component.ts @@ -101,7 +101,11 @@ export abstract class WidgetTimeEntriesListComponent extends AbstractWidgetCompo } public entityName(entry:TimeEntryResource):string { - return `#${entry.entity.id!}: ${entry.entity.name}`; + const entity = entry.entity; + const formattedId = entity instanceof WorkPackageResource + ? entity.formattedId + : `#${idFromLink(entity.href)}`; + return `${formattedId}: ${entity.name}`; } public entityId(entry:TimeEntryResource):string { @@ -145,7 +149,7 @@ export abstract class WidgetTimeEntriesListComponent extends AbstractWidgetCompo showClose: true, closeByDocument: true, passedData: [ - `#${idFromLink(entry.workPackage?.href)} ${entry.workPackage?.name}`, + entry.entity ? this.entityName(entry) : '', `${this.i18n.t( 'js.units.hour', { count: this.timezone.toHours(entry.hours) }, diff --git a/frontend/src/stimulus/controllers/dynamic/admin/jira-projects.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/jira-projects.controller.ts index cbd7adf5b33..be33b111854 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/jira-projects.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/jira-projects.controller.ts @@ -40,14 +40,21 @@ export default class extends Controller { debounce: {type: Number, default: 500}, }; + static targets = ['submitButton', 'spinnerButton']; static metaNames = ['csrf-token']; declare readonly csrfToken:string; - declare toggleUrlValue:string; - declare filterUrlValue:string; - declare debounceValue:number; + declare readonly toggleUrlValue:string; + declare readonly filterUrlValue:string; + declare readonly debounceValue:number; + declare readonly submitButtonTarget:HTMLElement; + declare readonly hasSubmitButtonTarget:boolean; + declare readonly spinnerButtonTarget:HTMLElement; + declare readonly hasSpinnerButtonTarget:boolean; private debouncedFilter:DebouncedFunc<(filter:string) => Promise> | null = null; + private requestQueue:(() => Promise)[] = []; + private drainingQueue = false; connect():void { useMeta(this, {suffix: false}); @@ -59,21 +66,35 @@ export default class extends Controller { disconnect():void { this.debouncedFilter?.cancel(); + this.requestQueue = []; } - async toggleProject(event:Event):Promise { + toggleProject(event:Event):void { const checkbox = event.currentTarget as HTMLInputElement; const projectId = checkbox.value; const url = this.toggleUrlValue.replace('PROJECT_ID', projectId); - const response = await fetch(url, { - headers: { - Accept: 'text/vnd.turbo-stream.html', - }, + this.enqueue(async () => { + const response = await fetch(url, { + headers: { + Accept: 'text/vnd.turbo-stream.html', + }, + }); + const html = await response.text(); + renderStreamMessage(html); }); + } - const html = await response.text(); - renderStreamMessage(html); + checkAll(event:Event):void { + event.preventDefault(); + const link = event.currentTarget as HTMLAnchorElement; + this.enqueue(() => this.submitBulkAction(link.href)); + } + + uncheckAll(event:Event):void { + event.preventDefault(); + const link = event.currentTarget as HTMLAnchorElement; + this.enqueue(() => this.submitBulkAction(link.href)); } filterProjects(event:Event):void { @@ -81,6 +102,50 @@ export default class extends Controller { void this.debouncedFilter?.(input.value); } + private enqueue(task:() => Promise):void { + this.requestQueue.push(task); + this.setSpinner(true); + if (!this.drainingQueue) { + this.drainingQueue = true; + this.processNextTask(); + } + } + + private processNextTask():void { + if (this.requestQueue.length === 0) { + this.setSpinner(false); + this.drainingQueue = false; + return; + } + + const task = this.requestQueue.shift()!; + task() + .then(() => { + setTimeout(() => { + this.setSpinner(this.requestQueue.length > 0); + this.processNextTask(); + }, 0); + }) + .catch((e:unknown) => { + console.warn(`Failed to change the project selection: ${e as string}`); + }); + } + + private setSpinner(visible:boolean):void { + if (this.hasSubmitButtonTarget) this.submitButtonTarget.hidden = visible; + if (this.hasSpinnerButtonTarget) this.spinnerButtonTarget.hidden = !visible; + } + + private async submitBulkAction(url:string):Promise { + const response = await fetch(url, { + headers: { + Accept: 'text/vnd.turbo-stream.html', + }, + }); + const html = await response.text(); + renderStreamMessage(html); + } + private async submitFilter(filter:string):Promise { const url = this.filterUrlValue; const formData = new FormData(); diff --git a/modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb b/modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb index 72b0ee8ec77..57c6816f785 100644 --- a/modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb +++ b/modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb @@ -76,14 +76,21 @@ module API def create_link_lambda(name, getter: "#{name}_id") ->(*) { - v3_path = API::V3::TimeEntries::EntityRepresenterFactory.representer_type(represented.send(name)) - title_attribute = API::V3::TimeEntries::EntityRepresenterFactory.title_attribute(represented.send(name)) + entity = represented.send(name) + v3_path = API::V3::TimeEntries::EntityRepresenterFactory.representer_type(entity) + title_attribute = API::V3::TimeEntries::EntityRepresenterFactory.title_attribute(entity) - instance_exec(&self.class.associated_resource_default_link_lambda(name, - v3_path:, - skip_link: -> { false }, - title_attribute:, - getter:)) + link = instance_exec(&self.class.associated_resource_default_link_lambda(name, + v3_path:, + skip_link: -> { false }, + title_attribute:, + getter:)) + + if link.is_a?(Hash) && entity.is_a?(WorkPackage) + link.merge(displayId: entity.display_id.to_s) + else + link + end } end diff --git a/modules/costs/spec/lib/api/v3/time_entries/time_entry_representer_rendering_spec.rb b/modules/costs/spec/lib/api/v3/time_entries/time_entry_representer_rendering_spec.rb index 91b334078c0..3c6f3fcd5c5 100644 --- a/modules/costs/spec/lib/api/v3/time_entries/time_entry_representer_rendering_spec.rb +++ b/modules/costs/spec/lib/api/v3/time_entries/time_entry_representer_rendering_spec.rb @@ -98,6 +98,23 @@ RSpec.describe API::V3::TimeEntries::TimeEntryRepresenter, "rendering" do let(:href) { api_v3_paths.work_package work_package.id } let(:title) { work_package.subject } end + + it "includes displayId in the entity link (classic mode numeric id)" do + expect(subject) + .to be_json_eql(work_package.display_id.to_s.to_json) + .at_path("_links/entity/displayId") + end + + context "with semantic identifier mode active", + with_settings: { work_packages_identifier: "semantic" } do + let(:work_package) { build_stubbed(:work_package, identifier: "PROJ-42", project: workspace) } + + it "includes the semantic displayId in the entity link" do + expect(subject) + .to be_json_eql("PROJ-42".to_json) + .at_path("_links/entity/displayId") + end + end end context "with a time entry logged on a meeting" do @@ -114,6 +131,10 @@ RSpec.describe API::V3::TimeEntries::TimeEntryRepresenter, "rendering" do it_behaves_like "has no link" do let(:link) { "workPackage" } end + + it "does not include displayId in the entity link" do + expect(subject).not_to have_json_path("_links/entity/displayId") + end end it_behaves_like "has a titled link" do diff --git a/spec/controllers/work_packages/bulk_controller_spec.rb b/spec/controllers/work_packages/bulk_controller_spec.rb index 3d9e23b5744..a0b3be44c65 100644 --- a/spec/controllers/work_packages/bulk_controller_spec.rb +++ b/spec/controllers/work_packages/bulk_controller_spec.rb @@ -681,6 +681,42 @@ RSpec.describe WorkPackages::BulkController, with_settings: { journal_aggregatio expect(new_parent.due_date).to eq(task2.due_date) end end + + describe "bulk parent assignment with semantic identifiers", + with_settings: { work_packages_identifier: "semantic" } do + let(:sem_project) do + create(:project, identifier: "SEMPROJ", types: [type]).tap do |p| + create(:member, project: p, principal: user, roles: [role]) + end + end + let(:parent_wp) { create(:work_package, project: sem_project).reload } + let(:child1) { create(:work_package, project: sem_project).reload } + let(:child2) { create(:work_package, project: sem_project).reload } + + it "accepts a semantic identifier and assigns the parent" do + put :update, + params: { + ids: [child1.id, child2.id], + work_package: { parent_id: parent_wp.identifier } + } + + expect(response).to have_http_status(:found) + expect(child1.reload.parent_id).to eq(parent_wp.id) + expect(child2.reload.parent_id).to eq(parent_wp.id) + end + + it "reports an error for an unknown semantic identifier" do + put :update, + params: { + ids: [child1.id, child2.id], + work_package: { parent_id: "SEMPROJ-9999" } + } + + expect(flash[:error]).to be_present + expect(child1.reload.parent_id).to be_nil + expect(child2.reload.parent_id).to be_nil + end + end end describe "#destroy" do diff --git a/spec/features/admin/import/jira/select_projects_spec.rb b/spec/features/admin/import/jira/select_projects_spec.rb new file mode 100644 index 00000000000..ea49c45731f --- /dev/null +++ b/spec/features/admin/import/jira/select_projects_spec.rb @@ -0,0 +1,254 @@ +# 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 "Jira import select projects modal", :js do + shared_let(:admin) { create(:admin) } + shared_let(:jira) { create(:jira) } + + current_user { admin } + + let(:available_projects) do + [ + { "id" => "10001", "name" => "Project Alpha", "key" => "ALPHA" }, + { "id" => "10002", "name" => "Project Beta", "key" => "BETA" }, + { "id" => "10003", "name" => "Gamma Project", "key" => "GAMMA" } + ] + end + + let(:jira_import) do + create(:jira_import, jira:, author: admin).tap do |import| + import.transition_to!(:instance_meta_fetching) + import.transition_to!(:instance_meta_done) + import.transition_to!(:configuring) + import.update!(available: { "projects" => available_projects }) + end + end + + let(:modal_id) { Admin::Import::Jira::ImportRuns::SelectProjects::ModalComponent::MODAL_ID } + let(:filter_label) { I18n.t(:"admin.jira.run.wizard.select_dialog.filter_projects") } + + before do + allow(Import::JiraInstanceMetaDataJob).to receive(:perform_later) + allow(Import::JiraProjectsMetaDataJob).to receive(:perform_later) + allow(Import::JiraFetchAndImportProjectsJob).to receive(:perform_later) + allow(Import::JiraRevertImportJob).to receive(:perform_later).and_return(double(job_id: "job-stub")) + allow(Import::JiraFinalizeImportJob).to receive(:perform_later) + visit admin_import_jira_run_path(jira_id: jira.id, id: jira_import.id) + end + + def open_select_projects_modal + click_on I18n.t(:"admin.jira.run.wizard.sections.import_scope.button_select") + expect(page).to have_css("##{modal_id}[open]") + end + + # Primer IconButton moves `aria-label` to a hidden `` web component + # and sets `aria-labelledby` on the button. Find the button via the tooltip's `for` attribute. + def pagination_button_for(label) + tooltip = find("tool-tip", text: label, visible: :all) + find("[id='#{tooltip[:for]}']") + end + + # `fill_in with: ""` does not fire an `input` event in Cuprite (Ferrum skips + # the type step for empty strings). Dispatch the event manually so the + # debounced filter Stimulus action picks it up. + def clear_filter + find("[name='filter']").set("") + page.execute_script("document.querySelector('[name=\"filter\"]').dispatchEvent(new Event('input', {bubbles:true}))") + end + + it "opens dialog showing all projects unchecked, with title and key captions" do + open_select_projects_modal + + expect(page).to have_text(I18n.t(:"admin.jira.run.wizard.select_projects.title")) + expect(page).to have_field("Project Alpha", type: :checkbox, checked: false) + expect(page).to have_field("Project Beta", type: :checkbox, checked: false) + expect(page).to have_field("Gamma Project", type: :checkbox, checked: false) + within("##{modal_id}") do + expect(page).to have_text("ALPHA") + expect(page).to have_text("BETA") + expect(page).to have_text("GAMMA") + end + end + + it "restores previously saved selection when opening" do + jira_import.update!(projects: [{ "id" => "10001", "name" => "Project Alpha", "key" => "ALPHA" }]) + visit admin_import_jira_run_path(jira_id: jira.id, id: jira_import.id) + open_select_projects_modal + + expect(page).to have_field("Project Alpha", checked: true) + expect(page).to have_field("Project Beta", checked: false) + end + + describe "filtering" do + before { open_select_projects_modal } + + it "filters by name, key, and case; shows a no-results notice; and clears back to the full list" do + fill_in filter_label, with: "Alpha" + expect(page).to have_field("Project Alpha") + expect(page).to have_no_field("Project Beta") + expect(page).to have_no_field("Gamma Project") + + fill_in filter_label, with: "BETA" + expect(page).to have_field("Project Beta") + expect(page).to have_no_field("Project Alpha") + + fill_in filter_label, with: "gamma" + expect(page).to have_field("Gamma Project") + expect(page).to have_no_field("Project Alpha") + + fill_in filter_label, with: "ZZNOTFOUND" + expect(page).to have_css(".op-toast.-info") + expect(page).to have_no_field("Project Alpha") + + clear_filter + expect(page).to have_field("Project Alpha") + expect(page).to have_field("Project Beta") + expect(page).to have_field("Gamma Project") + end + end + + describe "bulk selection" do + before { open_select_projects_modal } + + it "checks and unchecks all visible projects" do + click_on I18n.t(:button_check_all) + expect(page).to have_field("Project Alpha", checked: true) + expect(page).to have_field("Project Beta", checked: true) + expect(page).to have_field("Gamma Project", checked: true) + + click_on I18n.t(:button_uncheck_all) + expect(page).to have_field("Project Alpha", checked: false) + expect(page).to have_field("Project Beta", checked: false) + expect(page).to have_field("Gamma Project", checked: false) + end + + it "scopes bulk check and uncheck to visible filtered projects" do + fill_in filter_label, with: "Alpha" + expect(page).to have_no_field("Project Beta") + click_on I18n.t(:button_check_all) + clear_filter + expect(page).to have_field("Project Alpha", checked: true) + expect(page).to have_field("Project Beta", checked: false) + expect(page).to have_field("Gamma Project", checked: false) + + click_on I18n.t(:button_check_all) + fill_in filter_label, with: "Alpha" + expect(page).to have_no_field("Project Beta") + click_on I18n.t(:button_uncheck_all) + clear_filter + expect(page).to have_field("Project Alpha", checked: false) + expect(page).to have_field("Project Beta", checked: true) + expect(page).to have_field("Gamma Project", checked: true) + end + end + + describe "individual selection" do + before { open_select_projects_modal } + + it "tracks the selection counter and shows the submit button once all requests drain" do + check "Project Alpha" + within("[data-admin--jira-projects-target='submitButton']") do + expect(page).to have_text("1") + end + + check "Project Beta" + check "Gamma Project" + expect(page).to have_css("[data-admin--jira-projects-target='submitButton']:not([hidden])") + expect(page).to have_css("[data-admin--jira-projects-target='spinnerButton'][hidden]", visible: :all) + + uncheck "Project Beta" + within("[data-admin--jira-projects-target='submitButton']") do + expect(page).to have_text("2") + end + end + end + + describe "confirming selection" do + before { open_select_projects_modal } + + it "saves the selected projects, closes the dialog, and updates the wizard button count" do + check "Project Alpha" + check "Project Beta" + + within("[data-admin--jira-projects-target='submitButton']") do + click_on I18n.t(:button_continue) + end + + expect(page).to have_no_css("##{modal_id}[open]") + expect(jira_import.reload.projects).to contain_exactly( + { "id" => "10001", "name" => "Project Alpha", "key" => "ALPHA" }, + { "id" => "10002", "name" => "Project Beta", "key" => "BETA" } + ) + expect(page).to have_css("[data-controller='async-dialog']", text: "2") + end + + it "discards changes when cancelled" do + check "Project Alpha" + click_on I18n.t(:button_cancel) + expect(page).to have_no_css("##{modal_id}[open]") + expect(jira_import.reload.projects).to be_empty + end + end + + describe "pagination" do + let(:available_projects) do + (1..25).map { |i| { "id" => (10_000 + i).to_s, "name" => "Project #{i.to_s.rjust(2, '0')}", "key" => "PROJ#{i}" } } + end + + before { open_select_projects_modal } + + it "paginates results, disables nav at page boundaries, and preserves selections across pages" do + expect(page).to have_text("1 / 2") + expect(pagination_button_for(I18n.t(:label_previous))).to be_disabled + expect(page).to have_field("Project 01") + expect(page).to have_no_field("Project 21") + + check "Project 01" + pagination_button_for(I18n.t(:label_next)).click + + expect(page).to have_text("2 / 2") + expect(pagination_button_for(I18n.t(:label_next))).to be_disabled + expect(page).to have_field("Project 21") + expect(page).to have_no_field("Project 01") + + check "Project 21" + pagination_button_for(I18n.t(:label_previous)).click + + expect(page).to have_text("1 / 2") + expect(page).to have_field("Project 01", checked: true) + expect(page).to have_no_field("Project 21") + within("[data-admin--jira-projects-target='submitButton']") do + expect(page).to have_text("2") + end + end + end +end diff --git a/spec/mailers/sharing_mailer_spec.rb b/spec/mailers/sharing_mailer_spec.rb index ca0e658230e..1820ebdd7e7 100644 --- a/spec/mailers/sharing_mailer_spec.rb +++ b/spec/mailers/sharing_mailer_spec.rb @@ -64,6 +64,11 @@ RSpec.describe SharingMailer do expect(mail.subject) .to eq(I18n.t("mail.sharing.work_packages.subject", id: "##{work_package.id}")) end + + it "links to the work package by its numeric id" do + expect(mail.html_part.body.encoded).to include("/work_packages/#{work_package.id}") + expect(mail.text_part.body.encoded).to include("/work_packages/#{work_package.id}") + end end context "with semantic mode", @@ -81,6 +86,19 @@ RSpec.describe SharingMailer do expect(mail.subject) .to eq(I18n.t("mail.sharing.work_packages.subject", id: "PROJ-42")) end + + it "links to the work package by its semantic identifier, not the numeric id" do + expect(mail.html_part.body.encoded).to include("/work_packages/PROJ-42") + expect(mail.html_part.body.encoded).not_to include("/work_packages/#{work_package.id}") + + expect(mail.text_part.body.encoded).to include("/work_packages/PROJ-42") + expect(mail.text_part.body.encoded).not_to include("/work_packages/#{work_package.id}") + end + + it "renders the text-part heading with the semantic identifier, not the numeric id" do + expect(mail.text_part.body.encoded).to include("= PROJ-42 #{work_package.subject} =") + expect(mail.text_part.body.encoded).not_to include("##{work_package.id}") + end end it "has a project header" do diff --git a/spec/mailers/work_package_mailer_spec.rb b/spec/mailers/work_package_mailer_spec.rb index 93fab35f2f2..7275e8a2703 100644 --- a/spec/mailers/work_package_mailer_spec.rb +++ b/spec/mailers/work_package_mailer_spec.rb @@ -150,12 +150,18 @@ RSpec.describe WorkPackageMailer 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 + + it "links to the work package by its numeric id" do + expect(mail.html_part.body.to_s) + .to include("/notifications/details/#{parent_wp.id}/activity") + 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) + parent_wp.update_columns(identifier: "DEMO-2", sequence_number: 2) end it "renders the bare semantic identifier in the text body" do @@ -163,6 +169,12 @@ RSpec.describe WorkPackageMailer do expect(body).to include("DEMO-1") expect(body).not_to match(/##{referenced_wp.id}\b/) end + + it "links to the work package by its semantic identifier, not the numeric id" do + body = mail.html_part.body.to_s + expect(body).to include("/notifications/details/DEMO-2/activity") + expect(body).not_to include("/notifications/details/#{parent_wp.id}/activity") + end end end end