Merge branch 'release/17.5' into dev

This commit is contained in:
OpenProject Actions CI
2026-06-03 12:57:00 +00:00
47 changed files with 703 additions and 167 deletions
@@ -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")
@@ -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
@@ -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 %>
@@ -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
@@ -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",
@@ -42,7 +42,10 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
<% content.with_main(classes: "type-form-configuration-page--main") do %>
<div class="type-form-configuration-page--active-list" data-admin--type-form-configuration--rows-drag-and-drop-target="scrollContainer">
<%= 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?
)
) %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
@@ -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(
@@ -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: 1500.
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
@@ -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 %>
@@ -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: 1500.
def sample_wp_id(identifier)
n = (identifier.bytes.sum % 500) + 1
"#{identifier}-#{n}"
end
end
end
end
@@ -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
@@ -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
@@ -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
+1 -2
View File
@@ -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)
+2 -2
View File
@@ -1,5 +1,5 @@
<a style="text-decoration: none;display: block;"
href="<%= local_assigns[:notification_url] || details_notifications_url(work_package.id, tab: :activity) %>"
href="<%= local_assigns[:notification_url] || details_notifications_url(work_package, tab: :activity) %>"
target="_blank">
<%= render layout: "mailer/border_table" do %>
<tr>
@@ -105,7 +105,7 @@
</table>
<a style="margin-left: 12px;"
href="<%= defined?(open_in_browser_path) ? open_in_browser_path : details_notifications_url(work_package.id, tab: :activity) %>"
href="<%= defined?(open_in_browser_path) ? open_in_browser_path : details_notifications_url(work_package, tab: :activity) %>"
target="_blank"
rel="noopener noreferrer">
<%= I18n.t("mail.work_packages.open_in_browser") %>
@@ -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) %>
@@ -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
} %>
+1
View File
@@ -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
@@ -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/).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+4 -1
View File
@@ -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.
@@ -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]
+28 -22
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -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(
@@ -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) },
@@ -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<void>> | null = null;
private requestQueue:(() => Promise<void>)[] = [];
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<void> {
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>):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<void> {
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<void> {
const url = this.filterUrlValue;
const formData = new FormData();
@@ -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
@@ -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
@@ -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
@@ -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 `<tool-tip>` 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
+18
View File
@@ -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
+12
View File
@@ -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