Merge branch 'release/17.5' into dev
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||

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

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

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

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

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

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

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

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

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

|
||||
|
||||
## 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!
|
||||
|
||||
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
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'
|
||||
|
||||
@@ -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)'"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||