Merge branch 'release/17.5' into dev
@@ -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: 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
|
||||
@@ -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: 1–500.
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
} %>
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
2. Select the **role** for which you want to configure the workflow. The workflow table will update automatically when switching roles.
|
||||
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||

|
||||
|
||||
You can also copy to other roles by selecting a role or multiple target roles from the drop-down list.
|
||||
|
||||

|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
> [!TIP]
|
||||
> For more examples on using workflows in OpenProject take a look at [this blog article](https://www.openproject.org/blog/status-and-workflows/).
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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
|
||||
|
||||

|
||||
|
||||
### 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]
|
||||
|
||||
@@ -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:
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
> [!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.
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||