Merge branch 'release/17.5' into dev

This commit is contained in:
OpenProject Actions CI
2026-06-01 14:42:29 +00:00
55 changed files with 634 additions and 81 deletions
+1
View File
@@ -39,6 +39,7 @@
@import "work_packages/details/tab_component"
@import "work_packages/exports/modal_dialog_component"
@import "work_packages/hover_card_component"
@import "work_packages/info_line_component"
@import "work_packages/progress/modal_body_component"
@import "work_packages/reminder/modal_body_component"
@import "work_packages/split_view_component"
@@ -39,7 +39,7 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
<% grid.with_area(:info_line) do %>
<%= render(WorkPackages::InfoLineComponent.new(work_package:, status_scheme:)) %>
<%= render(WorkPackages::InfoLineComponent.new(work_package:, status_scheme:, wrap: false)) %>
<% end %>
<% grid.with_area(:actions) do %>
@@ -60,7 +60,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= render(
Primer::Beta::Text.new(
tag: :span,
classes: "__hl_inline_priority_#{work_package.priority.id} __hl_inline__small_dot"
classes: "__hl_inline_priority_#{work_package.priority.id} __hl_inline__small_dot no-wrap"
)
) do %>
<span class="op-work-package-card--priority-name"><%= work_package.priority.name %></span>
@@ -28,11 +28,12 @@
.op-work-package-card
display: grid
grid-template-columns: 1fr auto
grid-template-columns: minmax(8rem, 1fr) auto
grid-template-rows: auto auto
grid-template-areas: "info_line actions" "subject subject"
align-items: center
grid-row-gap: var(--base-size-4)
column-gap: var(--stack-gap-condensed)
row-gap: var(--base-size-4)
margin-top: var(--base-size-4)
margin-bottom: var(--base-size-4)
container-type: inline-size
@@ -42,7 +43,7 @@
grid-template-areas: "info_line actions" "subject subject" "footer footer"
&_with-drag-handle
grid-template-columns: auto 1fr auto
grid-template-columns: auto minmax(8rem, 1fr) auto
grid-template-areas: "drag_handle info_line actions" ". subject subject"
&.op-work-package-card_with-footer
@@ -98,6 +99,15 @@
// < 768px: hide text labels, show only icons
@container (width < 768px)
.op-work-package-card--assignee-name,
.op-work-package-card--assignee-name
display: none
// < 350px: hide priority label as well
@container (width < 350px)
.op-work-package-card--priority-name
display: none
// On mobile, hide the priority label as well
@media screen and (max-width: $breakpoint-sm)
.op-work-package-card--priority-name
display: none
@@ -41,14 +41,16 @@ See COPYRIGHT and LICENSE files for more details.
) %>
<% end %>
<% content.with_main(overflow: :auto, classes: "type-form-configuration-page--main") do %>
<%= render(
WorkPackageTypes::FormConfiguration::MainContentComponent.new(
type: @type,
group_components: group_components,
ee_available: ee_available?
)
) %>
<% 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">
<%= render(
WorkPackageTypes::FormConfiguration::MainContentComponent.new(
type: @type,
group_components: group_components,
ee_available: ee_available?
)
) %>
</div>
<% end %>
<% end %>
<% end %>
@@ -1,14 +1,14 @@
<%=
flex_layout(flex_wrap: :wrap, **@system_arguments) do |flex|
flex_layout(**@system_arguments) do |flex|
if @show_project && !@show_subject
flex.with_column(mr: 2) do
render(Primer::Beta::Text.new(font_size: @font_size)) { "#{@work_package.project.name}: " }
end
end
flex.with_column(mr: 2) do
flex.with_column(mr: 2, classes: "op-wp-info-line--type") do
render(WorkPackages::HighlightedTypeComponent.new(work_package: @work_package, font_size: :small))
end
flex.with_column do
flex.with_column(classes: "op-wp-info-line--id") do
render(
Primer::Beta::Link.new(
href: url_for(controller: "/work_packages", action: "show", id: @work_package),
@@ -20,13 +20,13 @@
end
if @show_status
flex.with_column(ml: 2) do
flex.with_column(ml: 2, classes: "op-wp-info-line--status") do
render WorkPackages::StatusBadgeComponent.new(status: @work_package.status, scheme: @status_scheme)
end
end
if @show_subject
flex.with_column(classes: "ellipsis", ml: 1) do
flex.with_column(classes: "ellipsis", ml: 2) do
render(Primer::Beta::Text.new(font_size: @font_size)) do
if @show_project
"#{@work_package.project.name}: #{@work_package.subject}"
@@ -37,6 +37,7 @@ class WorkPackages::InfoLineComponent < ApplicationComponent
show_status: true,
status_scheme: :default,
font_size: :small,
wrap: true,
**system_arguments)
super
@@ -46,7 +47,14 @@ class WorkPackages::InfoLineComponent < ApplicationComponent
@show_subject = show_subject
@show_status = show_status
@status_scheme = status_scheme
@wrap = wrap
@system_arguments = system_arguments
@system_arguments[:classes] = class_names(
@system_arguments[:classes],
"op-wp-info-line"
)
@system_arguments[:flex_wrap] = @wrap ? :wrap : :nowrap
@system_arguments[:overflow] = :hidden unless @wrap
end
end
@@ -0,0 +1,9 @@
.op-wp-info-line
&--type,
&--status
@include text-shortener
min-width: 25px
max-width: 66%
&--id
white-space: nowrap
+1 -1
View File
@@ -79,7 +79,7 @@ module WorkPackage::Journalized
def self.event_url
Proc.new do |o|
{ controller: :work_packages, action: :show, id: o.id }
{ controller: :work_packages, action: :show, id: o.display_id }
end
end
end
+1
View File
@@ -4694,6 +4694,7 @@ en:
label_total_days_off: "Total days off"
macro_execution_error: "Error executing the macro %{macro_name}"
macro_unavailable: "Macro %{macro_name} cannot be displayed."
macro_unknown: "Unknown or unsupported macro."
macros:
placeholder: "[Placeholder] Macro %{macro_name}"
errors:
+137 -13
View File
@@ -3,22 +3,144 @@ title: OpenProject 17.5.0
sidebar_navigation:
title: 17.5.0
release_version: 17.5.0
release_date: 2026-05-21
release_date: 2026-06-10
---
# OpenProject 17.5.0
Release date: 2026-05-21
Release date: 2026-06-10
We released [OpenProject 17.5.0](https://community.openproject.org/versions/2293). The release contains several bug fixes and we recommend updating to the newest version. In these Release Notes, we will give an overview of important feature changes. At the end, you will find a complete list of all changes and bug fixes.
We released [OpenProject 17.5.0](https://community.openproject.org/versions/2293).
The release contains several bug fixes and we recommend updating to the newest version.
In these Release Notes, we will give an overview of important feature changes. At the end, you will find a complete list of all changes and bug fixes.
## Important feature changes
<!-- Inform about the major features in this section -->
### Project-based work package identifiers for clearer references and Jira migrations
OpenProject 17.5 introduces **optional project-based work package identifiers in Beta**. Administrators can choose between the **default numerical sequence** and **project-based IDs** for the entire OpenProject instance.
> [!NOTE]
> The setting can be reverted later. Existing numerical IDs remain valid and continue to resolve to the same work packages throughout the application, including existing URLs, bookmarks, and references.
![OpenProject administration to select either 'Instance-wide numerical sequence (default)' or 'Project-based semantic identifiers'](openproject-17-5-identifiers-setting.png)
Project-based work package identifiers are especially useful for organizations migrating from Jira, as [existing Jira issue identifiers can now be preserved in OpenProject](#jira-migrator-support-for-jira-identifiers-due-dates-and-more). Beyond migrations, project-based IDs provide **shorter sequence numbers and clearer project context**, making it easier to recognize, reference, and share work packages across projects, emails, documents, chats, and integrations.
#### Switching between numerical and project-based IDs
Switching to project-based work package identifiers is an instance-wide administrative change that affects how work packages are referenced throughout OpenProject. Administrators should communicate the change to users before enabling it in production environments, as work package identifiers, URLs, and references will use the new format. OpenProject validates existing project identifiers and can automatically generate shorter, compatible identifiers where necessary.
> [!NOTE]
> Historical references remain functional when project identifiers change.
![OpenProject administration to configure project-based work package identifiers and convert project identifiers](openproject-17-5-project-based-identifier.png)
#### Support across URLs, searches, exports, and integrations
Even in Beta, project-based work package identifiers are supported across important areas of OpenProject, including URLs, searches, filters, exports, email notifications, APIs, and work package references in Documents and text editors.
Existing integrations such as GitHub and GitLab already support the new identifier format.
> [!NOTE]
> Project-based work package identifiers are still in Beta. While the feature is supported across important areas of OpenProject, **some areas may continue to display numerical identifiers until support for project-based identifiers is fully implemented**. In these cases, numerical identifiers remain fully functional and continue to resolve to the same work packages.
#### Releasing unused numerical identifiers
When switching from the default numerical sequence to project-based work package identifiers, previously reserved numerical identifiers can be released again if they are no longer needed. This helps administrators avoid unnecessary gaps and keep numerical identifiers available if they later revert to the default sequence.
> [!NOTE]
> Releasing an identifier cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim.
![OpenProject administration: Release reserved project identifiers](openproject-17-5-project-identifiers-release.png)
### Jira Migrator support for Jira identifiers, due dates, and more
OpenProject 17.5 further improves the Jira Migrator that was [introduced in Beta with OpenProject 17.4](../17-4-0/#support-basic-custom-fields-migration-from-jira). Jira issue identifiers can now be preserved during migration when using [project-based work package identifiers](#project-based-work-package-identifiers-for-clearer-references-and-jira-migrations).
This helps organizations maintain existing references, naming conventions, and established workflows when transitioning from Jira to OpenProject.
In addition to Jira identifiers, OpenProject 17.5 also adds support for migrating due dates, estimated hours, and remaining hours. [Read more about the Jira Migrator in our documentation](../../installation-and-operations/jira-migration/).
### Option to exclude work package types from Backlogs
OpenProject 17.5 introduces more flexible backlog configuration by allowing project administrators to exclude specific work package types from Backlogs. This helps teams keep sprint planning and backlog refinement focused on actionable work items.
For example, higher-level planning items such as Epics or Milestones can now be excluded from backlog views while still remaining available elsewhere in the project. The configuration is available in the Backlogs project settings and can be customized per project.
![Backlogs settings in OpenProject: Excluded work package types with example 'Candidate interview'](openproject-17-5-backlogs-seting-exclude-work-package-types.png)
OpenProject 17.5 also extends project-specific "done" status configuration to the Backlogs module. Work packages with statuses configured as done are now handled consistently across backlog views and sprint completion. For example, teams can treat development work as complete once testing is finished, even if documentation tasks remain open, allowing sprints to be completed without carrying over already finished development work.
[Read more about the OpenProject Backlogs module](../../system-admin-guide/backlogs/).
### Redesigned sprint views and work package cards
OpenProject 17.5 redesigns sprint headers, backlog containers, and work package cards in the Backlogs module to improve readability and usability during agile planning.
Sprint views now provide clearer visual hierarchy, more consistent actions, and improved visibility of important information such as parent work packages, story points, priorities, assignees, and sprint status. Work package cards have also been redesigned to make important work item details easier to scan during sprint planning and backlog refinement.
![Backlog and sprints view in OpenProject 17.5](openproject-17-5-backlogs-redesign.png)
### Allow inline work package links within text paragraphs in the Documents module
OpenProject 17.5 makes it easier to reference work packages naturally within Documents, which use the BlockNote editor. Work package links can now be inserted directly inside text paragraphs instead of always appearing as separate blocks.
This allows teams to create more readable and structured documentation while still linking directly to relevant work packages. Inline work package links behave like regular inline elements and continue to open the referenced work package in a new tab.
![OpenProject Documents with an example how to link work packages and display them in-line, using different sizes](openproject-17-5-documents-work-package-linking.png)
[Read more about OpenProject's Documents module](../../user-guide/documents/).
### Expanded work package mentions in CKEditor
OpenProject 17.5 also improves work package references in CKEditor-based text fields such as work package descriptions, agenda items in meetings, and wiki pages.
Work package mentions using the `##` and `###` notation now expand directly inside the editor. Instead of displaying only the identifier, OpenProject now shows additional context such as the work package type, status, and subject **while still editing**.
This makes referenced work packages easier to recognize without leaving the editor.
![Example of using macros in a work package description, where I can see the details of the macro I include while still editing](openproject-17-5-work-package-macros.png)
### Monthly scheduling options for meeting series
OpenProject 17.5 adds more flexible scheduling options for recurring meetings. Meeting series can now repeat monthly based on patterns such as the first Monday or last Friday of a month.
This makes it easier to schedule recurring coordination meetings, steering committees, retrospectives, or review meetings that follow common organizational schedules.
![OpenProject meeting with overlay to Edit, set to "Every month on the fifth"](openproject-17-5-meetings-monthly.png)
### Debounce meeting emails to reduce email noise
OpenProject 17.5 improves meeting-related email behavior by reducing unnecessary update emails while meetings are actively edited.
Instead of sending an email for every small change, OpenProject now consolidates multiple meeting updates into fewer emails. Emails are only sent after no further changes have been made for one minute. This helps reduce inbox noise during collaborative meeting preparation and editing.
[Read more about OpenProject's Meetings module](../../user-guide/meetings/).
### Nested groups for organizational structures and inherited permissions
OpenProject 17.5 introduces nested groups to better represent organizational structures such as departments, teams, or business units.
Groups can now contain subgroups, allowing administrators to model hierarchies directly in OpenProject. Permissions and memberships can also be inherited from parent groups, making it easier to manage access rights consistently across larger organizations.
![Mockup showing users and permissions sorted into hierarchical groups](openproject-17-5-nested-groups-mockup.png)
### Allow multi-selection of roles in workflow
OpenProject 17.5 improves workflow administration by allowing administrators to select and configure multiple roles at once in the workflow configuration. This makes it easier and faster to manage workflows across complex role setups and reduces repetitive configuration work for administrators.
![OpenProject workflows multimple roles selected](openproject-17-5-workflows-multi-select.png)
## Important updates and breaking changes
### Sprint sharing moved to the Corporate plan
With OpenProject 17.5, sprint sharing across projects is now part of the Corporate plan. Existing sprint sharing configurations remain available after updating, but creating, modifying, or reactivating shared sprint configurations now requires the Corporate plan.
Sprint sharing was introduced to support scaled agile planning scenarios across multiple projects and teams. Moving the feature to the Corporate plan allows OpenProject to continue investing in advanced cross-project planning capabilities for larger organizational setups.
> [!NOTE]
> Existing sprint sharing configurations remain available after updating to OpenProject 17.5, including sprint sharing that was previously handled through shared versions in older OpenProject versions. Existing configurations continue to be migrated during upgrades. However, once sprint sharing is disabled, reactivating it requires the Corporate plan.
<!-- Remove this section if empty, add to it in pull requests linking to tickets and provide information -->
<!-- BEGIN SECURITY FIXES AUTOMATED SECTION -->
@@ -154,12 +276,14 @@ In these Release Notes, we will give an overview of important feature changes. A
<!-- Warning: Anything above this line will be automatically removed by the release script -->
## Contributions
A very special thank you goes to our sponsors for this release.
Also a big thanks to our Community members for reporting bugs and helping us identify and provide fixes.
Special thanks for reporting and finding bugs go to Walid Ibrahim, billy kenne, Agustín Dall'Alba.
Last but not least, we are very grateful for our very engaged translation contributors on Crowdin, who translated quite a few OpenProject strings!
Would you like to help out with translations yourself?
Then take a look at our translation guide and find out exactly how you can contribute.
It is very much appreciated!
A very special thank you goes to Helmholtz-Zentrum Berlin, City of Cologne, Deutsche Bahn and ZenDiS for sponsoring released or upcoming features. Your support, alongside the efforts of our amazing Community, helps drive these innovations.
Also a big thanks to our Community members for reporting bugs and helping us identify and provide fixes. Special thanks for reporting and finding bugs go to Walid Ibrahim, billy kenne, and Agustín Dall'Alba.
Last but not least, we are very grateful for our very engaged translation contributors on Crowdin, who translated quite a few OpenProject strings! This release we would like to particularly thank the following users:
- [Đorđe Dželebdžić](https://crowdin.com/profile/djordje.dzelebdzic), for an outstanding number of translations into Serbian (Cyrillic).
- [tuananhhurc](https://crowdin.com/profile/ncaa), for a great number of translations into Vietnamese.
Would you like to help out with translations yourself? Then take a look at our [translation guide](../../contributions-guide/translate-openproject/) and find out exactly how you can contribute. It is very much appreciated!
Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

@@ -79,8 +79,8 @@
} @else {
<a
class="global-search--option"
[href]="wpPath(item.id)"
(click)="redirectToWp(item.id, $event)"
[href]="wpPath(item.displayId)"
(click)="redirectToWp(item.displayId, $event)"
style="line-height: 1"
>
<div class="global-search--option-meta">
@@ -61,7 +61,7 @@ $search-input-height: 30px
border-color: var(--header-item-font-color) !important
&:focus-within
@include spot-focus
@include focus
.ng-value-container
padding-left: 35px !important
@@ -97,7 +97,7 @@ $search-input-height: 30px
color: var(--body-font-color) !important
.ng-placeholder
color: var(--body-font-color) !important
.ng-dropdown-header
border-bottom: none
color: var(--fgColor-muted)
@@ -0,0 +1,115 @@
//-- 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.
//++
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { GlobalSearchInputComponent } from './global-search-input.component';
// followItem is verified through the prototype against a stand-in context, avoiding
// a real component instance whose many injected dependencies this branch never uses.
describe('GlobalSearchInputComponent#followItem', () => {
let wpPathArgs:string[];
let searchInScopeArgs:string[];
let context:Pick<GlobalSearchInputComponent, 'wpPath'|'selectedItem'> & { searchInScope:(scope:string) => void };
function callFollowItem(item:Parameters<GlobalSearchInputComponent['followItem']>[0]):void {
GlobalSearchInputComponent.prototype.followItem.call(context, item);
}
beforeEach(() => {
wpPathArgs = [];
searchInScopeArgs = [];
context = {
wpPath: (id:string):string => {
wpPathArgs.push(id);
// A fragment keeps followItem's window.location assignment from navigating the runner.
return '#stub';
},
selectedItem: undefined,
searchInScope: (scope:string):void => {
searchInScopeArgs.push(scope);
},
};
});
describe('when item is a work package resource', () => {
// Build a real WorkPackageResource off its prototype and feed it a HAL $source,
// so followItem exercises the production displayId getter rather than a stub.
function buildWorkPackage(source:{ id:number, displayId?:string }):WorkPackageResource {
const item = Object.create(WorkPackageResource.prototype) as WorkPackageResource;
item.$source = source;
return item;
}
it('is recognised as a HalResource', () => {
expect(buildWorkPackage({ id: 42 }) instanceof HalResource).toBe(true);
});
describe('in semantic mode (source carries a semantic displayId)', () => {
let item:WorkPackageResource;
beforeEach(() => {
item = buildWorkPackage({ id: 42, displayId: 'PROJ-42' });
});
it('navigates via the semantic displayId, not the numeric id', () => {
callFollowItem(item);
expect(wpPathArgs).toEqual(['PROJ-42']);
expect(wpPathArgs).not.toContain('42');
});
it('sets selectedItem to the item', () => {
callFollowItem(item);
expect(context.selectedItem).toBe(item);
});
});
describe('in classic mode (source has only the numeric id)', () => {
it('falls back to the numeric id through displayId', () => {
callFollowItem(buildWorkPackage({ id: 42 }));
expect(wpPathArgs).toEqual(['42']);
});
});
});
describe('when item is a scope option (not a HalResource)', () => {
it('delegates to searchInScope and does not call wpPath', () => {
callFollowItem({ projectScope: 'current_project', text: 'In this project ↵' });
expect(searchInScopeArgs).toEqual(['current_project']);
expect(wpPathArgs).toEqual([]);
});
});
describe('when item is undefined', () => {
it('does nothing', () => {
callFollowItem(undefined);
expect(wpPathArgs).toEqual([]);
expect(searchInScopeArgs).toEqual([]);
});
});
});
@@ -262,8 +262,8 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy {
public followItem(item:WorkPackageResource|SearchOptionItem|undefined):void {
this.selectedItem = item;
if (item instanceof HalResource) {
window.location.href = this.wpPath(item.id!);
if (item instanceof WorkPackageResource) {
window.location.href = this.wpPath(item.displayId);
} else if (item) {
this.searchInScope(item.projectScope);
}
@@ -1,3 +0,0 @@
:focus-visible
@include spot-focus
@@ -1,4 +1,3 @@
@import './focus'
@import './container'
@import './container-overrides'
@import './typography'
@@ -23,7 +23,7 @@
box-sizing: border-box
&:focus
@include spot-focus
@include focus
&:hover
background-color: $spot-color-basic-gray-6
@@ -7,7 +7,7 @@
flex-shrink: 0
&:focus-within &--fake
@include spot-focus
@include focus
&, *
box-sizing: border-box
@@ -7,7 +7,7 @@
cursor: pointer
&:focus
@include spot-focus
@include focus
&_danger
color: $spot-color-danger
@@ -28,7 +28,7 @@
scroll-margin-top: $spot-spacing-5
&:focus
@include spot-focus
@include focus
&:hover
background-color: var(--control-transparent-bgColor-hover)
@@ -16,7 +16,7 @@
&:focus,
&_focused
color: $spot-color-basic-black
@include spot-focus
@include focus
&:disabled,
&_disabled
@@ -64,7 +64,7 @@
color: var(--fgColor-muted)
&:focus
@include spot-focus
@include focus
&:not(:last-child)
margin-right: $spot-spacing-0_5
@@ -21,7 +21,7 @@
cursor: pointer
&:focus-within
@include spot-focus
@include focus
&:not(:last-child)
border-right: 0
@@ -1,5 +0,0 @@
$spot-focus: 2px solid $spot-color-indication-attention
@mixin spot-focus
outline: $spot-focus
outline-offset: -2px
@@ -1,4 +1,3 @@
@import '../../tokens/dist/tokens'
@import 'typography'
@import 'zindex'
@import 'focus'
+1 -2
View File
@@ -16,7 +16,6 @@
"spot-color-danger": "rgb(208, 17, 0)",
"spot-color-danger-dark": "rgb(125, 0, 13)",
"spot-color-danger-light": "rgb(244, 171, 169)",
"spot-color-indication-attention": "rgb(0, 163, 255)",
"spot-color-indication-flagged": "rgb(28, 182, 192)",
"spot-color-indication-current-date": "rgb(255, 255, 225)",
"spot-color-feedback-error-dark": "rgb(202, 63, 63)",
@@ -60,4 +59,4 @@
"spot-mq-action-bar-change": "'(min-width: 768px)'",
"spot-mq-mobile": "'(max-width: 679px)'",
"spot-mq-desktop": "'(min-width: 680px)'"
}
}
-1
View File
@@ -16,7 +16,6 @@ $spot-color-accent-light: rgb(191, 238, 182)
$spot-color-danger: rgb(208, 17, 0)
$spot-color-danger-dark: rgb(125, 0, 13)
$spot-color-danger-light: rgb(244, 171, 169)
$spot-color-indication-attention: rgb(0, 163, 255)
$spot-color-indication-flagged: rgb(28, 182, 192)
$spot-color-indication-current-date: rgb(255, 255, 225)
$spot-color-feedback-error-dark: rgb(202, 63, 63)
@@ -925,7 +925,7 @@ input[readonly].-clickable
border-bottom-right-radius: 2px
&:focus-within
@include spot-focus
@include focus
.form--list
display: flex
@@ -49,6 +49,10 @@ body:has(.type-form-configuration-page)
@include styled-scroll-bar
min-height: 0
&--active-list
overflow: auto
height: 100%
&--inactive-list
list-style: none
margin: 0
@@ -25,6 +25,7 @@
//
// See COPYRIGHT and LICENSE files for more details.
//++
@import mixins
a
text-decoration: var(--link-text-decoration)
@@ -129,3 +130,6 @@ a
.-error-font
color: var(--content-form-error-color) !important
:focus-visible
@include focus
@@ -346,6 +346,10 @@ $scrollbar-size: 10px
color: white
flex-shrink: 0
@mixin focus
outline: var(--focus-outline, 2px solid #0969da)
outline-offset: -2px
@mixin show-on-focus
position: absolute
right: -10000px
@@ -41,9 +41,13 @@ module OpenProject::TextFormatting
def call # rubocop:disable Metrics/AbcSize
doc.search("macro").each do |macro|
matched = false
registered.each do |macro_class|
next unless macro_applies?(macro_class, macro)
matched = true
# If requested to skip macro expansion, do that
if context[:disable_macro_expansion]
macro.replace macro_placeholder(macro_class)
@@ -60,6 +64,8 @@ module OpenProject::TextFormatting
break
end
end
macro.replace unknown_macro_placeholder unless matched
end
doc
@@ -67,6 +73,13 @@ module OpenProject::TextFormatting
private
def unknown_macro_placeholder
ApplicationController.helpers.content_tag :macro,
I18n.t(:macro_unknown),
class: "macro-unavailable",
data: { macro_name: "unknown" }
end
def macro_error_placeholder(macro_class, message)
ApplicationController.helpers.content_tag :macro,
"#{I18n.t(:macro_execution_error,
@@ -52,8 +52,8 @@ module OpenProject::TextFormatting
remove_contents: Array(base[:remove_contents]) | %w[svg style],
attributes: base_attrs.deep_merge(
# Whitelist class and data-* attributes on all macros
"macro" => ["class", :data],
# Explicit allowlist of data-* attributes used by registered macros.
"macro" => %w[class data-type data-classes data-page data-include-parent data-macro-name data-query-props data-pull-request-id data-pull-request-state],
# mentions
"mention" => %w[data-type data-text data-id class],
# add styles to tables
@@ -28,9 +28,9 @@
# See COPYRIGHT and LICENSE files for more details.
#++
# This now only seems to be used when rendering atom responses.
# Search as well as activities do not rely on it.
# Thus, whenever an atom link is removed for a resource, acts_as_event within that model can also be removed.
# The event_* methods defined here power the server-rendered search results page
# and atom feeds. The Activities subsystem renders through its own providers, so
# a model can drop acts_as_event only once neither search nor atom references it.
module Redmine
module Acts
@@ -37,13 +37,16 @@ module OpenProject::WorkPackages
# @param show_status [Boolean]
# @param font_size [Symbol] select [small, normal]
# @param status_scheme select [default, secondary]
def playground(show_project: false, show_subject: false, show_status: true, font_size: :small, status_scheme: :default)
# @param wrap [Boolean]
def playground(show_project: false, show_subject: false, show_status: true, font_size: :small, status_scheme: :default,
wrap: true)
render(WorkPackages::InfoLineComponent.new(work_package: WorkPackage.visible.first,
show_project:,
show_subject:,
show_status:,
status_scheme:,
font_size:))
font_size:,
wrap:))
end
end
end
@@ -48,6 +48,11 @@ module Storages::ProjectStorages
attribute :project_folder_id
validates :project_folder_id, presence: true, if: :project_folder_mode_manual?
# For automatically managed folders, the project_folder_id should not even be choosable by the user, but in any case
# we have to make sure to not introduce duplicates, because this could lead to one project taking ownership of a folder
# that already belongs to another project
validate :project_folder_id_unique, if: :project_folder_mode_automatic?
attribute :project_folder_mode do
if Storages::ProjectStorage.project_folder_modes.keys.exclude?(@model.project_folder_mode)
errors.add :project_folder_mode, :invalid
@@ -62,10 +67,26 @@ module Storages::ProjectStorages
@model.project_folder_manual?
end
def project_folder_mode_automatic?
@model.project_folder_automatic?
end
def project_folder_mode_available_for_storage
if storage&.available_project_folder_modes&.exclude?(@model.project_folder_mode)
errors.add :project_folder_mode, :mode_unavailable
end
end
def project_folder_id_unique
return if @model.project_folder_id.blank?
return if @model.storage.blank?
collision = ::Storages::ProjectStorage
.where(storage_id: @model.storage_id, project_folder_id: @model.project_folder_id)
.where.not(id: @model.id)
.exists?
errors.add(:project_folder_id, :taken) if collision
end
end
end
@@ -141,7 +141,7 @@ class Storages::Admin::ProjectStoragesController < Projects::SettingsController
.require(:storages_project_storage)
.permit("storage_id", "project_folder_mode", "project_folder_id")
.to_h
.reverse_merge(project_id: @project.id)
.merge(project_id: @project.id)
end
def project_folder_mode_from_params
@@ -108,6 +108,12 @@ module Storages
# rubocop:disable Metrics/AbcSize
def set_folder_permissions(remote_admins, project_storage)
project_folder_id = project_storage.project_folder_id
if project_folder_id_collision?(project_storage)
return add_error(:set_folder_permission, collision_error, options: { folder: project_folder_id })
end
admin_permissions = remote_admins.to_set.map { |username| { user_id: username, permissions: FILE_PERMISSIONS } }
base_permissions = base_remote_permissions(admin_permissions)
@@ -117,7 +123,6 @@ module Storages
end
permissions = base_permissions + users_permissions
project_folder_id = project_storage.project_folder_id
input_data = build_set_permissions_input_data(project_folder_id, permissions).value_or do |failure|
log_validation_error(failure, project_folder_id:, permissions:)
@@ -159,6 +164,19 @@ module Storages
end
end
# We refuse to overwrite an ACL when another project_storage on the same storage holds the same folder id
# (defense in depth in case the contract check is bypassed or a folder id collision is written directly to the DB)
def project_folder_id_collision?(project_storage)
::Storages::ProjectStorage
.where(storage_id: project_storage.storage_id, project_folder_id: project_storage.project_folder_id)
.where.not(id: project_storage.id)
.exists?
end
def collision_error
::Storages::Adapters::Results::Error.new(source: self.class, code: :folder_id_collision)
end
### Model Scopes
def remote_identities
@@ -62,6 +62,12 @@ module Storages
def apply_permission_to_folders
info "Setting permissions to project folders"
@project_storages.includes(:project).with_project_folder.find_each do |project_storage|
project_folder_id = project_storage.project_folder_id
if project_folder_id_collision?(project_storage)
next add_error(:set_folder_permission, collision_error, options: { folder: project_folder_id })
end
permissions = admin_remote_identities_scope.pluck(:origin_user_id).map do |origin_user_id|
{ user_id: origin_user_id, permissions: [:write_files] }
end
@@ -72,7 +78,6 @@ module Storages
info "Setting permissions for #{project_storage.managed_project_folder_name}: #{permissions}"
project_folder_id = project_storage.project_folder_id
build_permissions_input_data(project_folder_id, permissions)
.either(
->(input_data) { set_permissions.call(storage: @storage, auth_strategy:, input_data:) },
@@ -103,6 +108,19 @@ module Storages
end
end
# We refuse to overwrite an ACL when another project_storage on the same storage holds the same folder id
# (defense in depth in case the contract check is bypassed or a folder id collision is written directly to the DB)
def project_folder_id_collision?(project_storage)
::Storages::ProjectStorage
.where(storage_id: project_storage.storage_id, project_folder_id: project_storage.project_folder_id)
.where.not(id: project_storage.id)
.exists?
end
def collision_error
::Storages::Adapters::Results::Error.new(source: self.class, code: :folder_id_collision)
end
def client_remote_identities_scope
RemoteIdentity.includes(:user).where(integration: @storage)
end
+4
View File
@@ -113,21 +113,25 @@ en:
remote_folders: 'Read contents of the team folder:'
remove_user_from_group: 'Remove User from Group:'
rename_project_folder: 'Rename managed project Folder:'
set_folder_permission: 'Set managed project Folder permissions:'
one_drive_sync_service:
create_folder: 'Managed Project Folder Creation:'
ensure_root_folder_permissions: 'Set Base Folder Permissions:'
hide_inactive_folders: 'Hide Inactive Folders Step:'
remote_folders: 'Read contents of the drive root folder:'
rename_project_folder: 'Rename managed project Folder:'
set_folder_permission: 'Set managed project Folder permissions:'
sharepoint_sync_service:
create_folder: 'Managed Project Folder Creation:'
ensure_root_folder_permissions: 'Set Base Folder Permissions:'
hide_inactive_folders: 'Hide Inactive Folders Step:'
remote_folders: 'Read contents of the drive root folder:'
rename_project_folder: 'Rename managed project Folder:'
set_folder_permission: 'Set managed project Folder permissions:'
errors:
messages:
error: An unexpected error occurred. Please check OpenProject logs for more information or contact an administrator
folder_id_collision: Multiple project storages try to manage the project folder %{folder}. Its synchronization was skipped.
forbidden: OpenProject could not access the requested resource. Please check your permissions configuration on the Storage Provider.
unauthorized: OpenProject could not authenticate with the Storage Provider. Please ensure that you have access to it.
models:
@@ -52,15 +52,32 @@ RSpec.describe Storages::ProjectStorages::BaseContract do
end
it_behaves_like "contract is valid"
end
context "when the project folder mode is `automatic` but the storage is not automatically managed" do
before do
project_storage.project_folder_mode = "automatic"
project_storage.storage.automatic_management_enabled = false
context "and when the storage is not automatically managed" do
before do
project_storage.storage.automatic_management_enabled = false
end
it_behaves_like "contract is invalid", project_folder_mode: :mode_unavailable
end
it_behaves_like "contract is invalid", project_folder_mode: :mode_unavailable
context "and when setting project storage to an existing project_folder_id" do
before do
create(:project_storage, storage:, project_folder_id: "existing-project-folder")
project_storage.project_folder_id = "existing-project-folder"
end
it_behaves_like "contract is invalid", project_folder_id: :taken
end
context "and when setting project storage to project_folder_id existing on other storage" do
before do
create(:project_storage, project_folder_id: "existing-project-folder")
project_storage.project_folder_id = "existing-project-folder"
end
it_behaves_like "contract is valid"
end
end
context "if the project folder mode is `manual`" do
@@ -79,6 +96,15 @@ RSpec.describe Storages::ProjectStorages::BaseContract do
it_behaves_like "contract is valid"
end
context "and when setting project storage to an existing project_folder_id" do
before do
create(:project_storage, storage:, project_folder_id: "existing-project-folder")
project_storage.project_folder_id = "existing-project-folder"
end
it_behaves_like "contract is valid"
end
end
include_examples "contract reuses the model errors"
@@ -86,7 +112,7 @@ RSpec.describe Storages::ProjectStorages::BaseContract do
describe "For a nextcloud storage" do
let(:contract) { described_class.new(project_storage, build_stubbed(:admin)) }
let(:storage) { build_stubbed(:nextcloud_storage) }
let(:storage) { create(:nextcloud_storage) }
let(:project_storage) { build(:project_storage, storage:) }
it_behaves_like "a ProjectStorage BaseContract"
@@ -104,7 +130,7 @@ RSpec.describe Storages::ProjectStorages::BaseContract do
describe "For a one drive storage" do
let(:contract) { described_class.new(project_storage, build_stubbed(:admin)) }
let(:storage) { build_stubbed(:one_drive_storage) }
let(:storage) { create(:one_drive_storage) }
let(:project_storage) { build(:project_storage, storage:) }
it_behaves_like "a ProjectStorage BaseContract"
@@ -160,7 +160,7 @@ module Storages
end
end
it "adds and remove users from the remote group", vcr: "nextcloud/managed_folder_set_permissions_group_users" do
it "adds and removes users from the remote group", vcr: "nextcloud/managed_folder_set_permissions_group_users" do
service.call
users = Adapters::Input::GroupUsers.build(group: storage.group).bind do |input_data|
@@ -175,6 +175,24 @@ module Storages
end
end
end
context "when two project storages have the same project_folder_id (regression #75022)" do
before do
create(:project_storage, storage:, project_folder_id: project_storage.project_folder_id)
end
it "fails with an appropriate error", vcr: "nextcloud/managed_folder_set_permissions" do
result = service.call
expect(result).to be_failure
expect(result.errors.details[:set_folder_permission]).to contain_exactly(
error: :folder_id_collision,
folder: project_storage.project_folder_id
)
# Ensure presence of a translation
expect(result.errors.full_messages).to all(be_a(String))
end
end
end
private
@@ -278,6 +278,24 @@ module Storages
expect(remote_permissions_for(inactive_project_storage)).to be_empty
end
context "and when two project storages have the same project_folder_id (regression #75022)" do
before do
create(:project_storage, storage:, project_folder_id: project_storage.project_folder_id)
end
it "fails with an appropriate error", vcr: "one_drive/sync_service_set_permissions" do
result = service.call
expect(result).to be_failure
expect(result.errors.details[:set_folder_permission]).to contain_exactly(
error: :folder_id_collision,
folder: project_storage.project_folder_id
)
# Ensure presence of a translation
expect(result.errors.full_messages).to all(be_a(String))
end
end
end
context "when the project is public", vcr: "one_drive/sync_service_public_project" do
+23
View File
@@ -461,6 +461,29 @@ RSpec.describe "Search", :js, :selenium, with_settings: { per_page_options: "5"
end
end
describe "when semantic work package IDs are active",
with_settings: { work_packages_identifier: "semantic" } do
let(:run_visit) { false }
let(:semantic_project) { create(:project, :semantic) }
let(:semantic_wp) do
create(:work_package, subject: "SemanticIdentifierTest WP", project: semantic_project)
end
before do
semantic_wp
visit search_path(scope: "all", q: "SemanticIdentifierTest")
end
it "links results to the semantic identifier URL, not the numeric ID" do
identifier = semantic_wp.reload.identifier
within("dt.work_package-edit") do
expect(page).to have_link(href: %r{/work_packages/#{Regexp.escape(identifier)}(?:$|[#?])})
expect(page).to have_no_link(href: %r{/work_packages/#{semantic_wp.id}(?:$|[#?])})
end
end
end
describe "search for notes" do
let(:work_package) { work_packages[0] }
let!(:note_one) do
@@ -0,0 +1,109 @@
# 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 "macro element attribute handling" do # rubocop:disable RSpec/DescribeClass
def sanitize(html)
filter = OpenProject::TextFormatting::Filters::SanitizationFilter.new(html, {})
result = filter.call
result.respond_to?(:to_html) ? result.to_html : result.to_s
end
def apply_macro_filter(html)
filter = OpenProject::TextFormatting::Filters::MacroFilter.new(html, {})
result = filter.call
result.respond_to?(:to_html) ? result.to_html : result.to_s
end
describe OpenProject::TextFormatting::Filters::SanitizationFilter do
describe "macro element data attribute restrictions" do
it "strips data-controller from macro elements" do
html = '<macro class="x" data-controller="poll-for-changes">.</macro>'
expect(sanitize(html)).not_to include("data-controller")
end
it "strips data-action from macro elements" do
html = '<macro class="x" data-action="click->foo#bar">.</macro>'
expect(sanitize(html)).not_to include("data-action")
end
it "strips arbitrary data-* stimulus value attributes from macro elements" do
html = '<macro class="x" data-poll-for-changes-url-value="/api/v3/attachments/1/content" ' \
'data-poll-for-changes-interval-value="2000">.</macro>'
output = sanitize(html)
expect(output).not_to include("data-poll-for-changes-url-value")
expect(output).not_to include("data-poll-for-changes-interval-value")
end
it "strips data-controller from arbitrary non-macro elements" do
html = '<div data-controller="poll-for-changes"><p data-controller="evil">text</p></div>'
output = sanitize(html)
expect(output).not_to include("data-controller")
end
it "preserves data-type on macro elements (used by create-work-package-link macro)" do
html = '<macro class="create-work-package-link" data-type="Task">.</macro>'
expect(sanitize(html)).to include('data-type="Task"')
end
it "preserves data-page on macro elements (used by child-pages macro)" do
html = '<macro class="child-pages" data-page="some-page" data-include-parent="true">.</macro>'
output = sanitize(html)
expect(output).to include('data-page="some-page"')
expect(output).to include('data-include-parent="true"')
end
it "preserves data-macro-name on macro elements (used by placeholder rendering)" do
html = '<macro class="macro-placeholder" data-macro-name="toc">placeholder</macro>'
expect(sanitize(html)).to include('data-macro-name="toc"')
end
end
end
describe OpenProject::TextFormatting::Filters::MacroFilter do
describe "unrecognized macro elements" do
it "replaces macro elements whose class does not match any registered macro with an unavailable placeholder" do
html = '<p><macro class="x">.</macro></p>'
output = apply_macro_filter(html)
expect(output).not_to include('class="x"')
expect(output).to include("macro-unavailable")
expect(output).to include("Unknown or unsupported macro.")
end
it "replaces macro elements with no class with an unavailable placeholder" do
html = "<p><macro>.</macro></p>"
output = apply_macro_filter(html)
expect(output).to include("macro-unavailable")
expect(output).to include("Unknown or unsupported macro.")
end
end
end
end
@@ -35,9 +35,21 @@ RSpec.describe WorkPackage do
let(:stub_work_package) { build_stubbed(:work_package) }
describe "#event_url" do
let(:expected_url) { { controller: :work_packages, action: :show, id: stub_work_package.id } }
context "in classic mode" do
let(:expected_url) { { controller: :work_packages, action: :show, id: stub_work_package.id } }
it { expect(stub_work_package.event_url).to eq(expected_url) }
it { expect(stub_work_package.event_url).to eq(expected_url) }
end
context "in semantic mode", with_settings: { work_packages_identifier: "semantic" } do
let(:project) { create(:project, :semantic) }
let(:work_package) { create(:work_package, project:) }
it "links to the semantic identifier rather than the numeric id" do
expect(work_package.event_url)
.to eq(controller: :work_packages, action: :show, id: work_package.reload.identifier)
end
end
end
end
end