Merge branch 'dev' into code-maintenance/73798-remove-scrum_projects-feature-flag

This commit is contained in:
Alexander Brandon Coles
2026-04-20 08:40:52 +01:00
34 changed files with 586 additions and 370 deletions
@@ -0,0 +1,81 @@
<%#
-- 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.
++#
%>
<%=
render(
Primer::OpenProject::DangerDialog.new(
id:,
title:,
form_arguments: {
action: form_action,
method: :delete,
data: { turbo: false }
},
size: :medium_portrait
)
) do |dialog|
%>
<% dialog.with_confirmation_message do |message|
message.with_heading(tag: :h2) { heading }
message.with_description_content(description)
end %>
<% dialog.with_additional_details(display: :block) do %>
<% if multiple_projects? %>
<%= render(Primer::Alpha::Banner.new(mb: 2, scheme: :warning, icon: :alert)) do %>
<%= I18n.t("work_packages.bulk_delete_dialog.cross_project_warning", projects: project_names) %>
<% end %>
<% end %>
<%= render(OpPrimer::InsetBoxComponent.new) do %>
<% work_packages.each do |wp| %>
<%= render WorkPackages::InfoLineComponent.new(work_package: wp,
show_subject: true,
show_status: false,
show_project: multiple_projects?) %>
<% if descendants_for(wp).any? %>
<%= render(Primer::Box.new(pl: 2, my: 1)) do %>
<%= render(Primer::Beta::Text.new(mt: 1, font_size: :small, font_weight: :bold, display: :block, mb: 1)) do %>
<%= I18n.t("work_packages.bulk_delete_dialog.children_label") %>
<% end %>
<% descendants_for(wp).each do |descendant| %>
<%= render WorkPackages::InfoLineComponent.new(work_package: descendant,
show_subject: true,
show_status: false,
show_project: descendant.project != wp.project) %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
<% dialog.with_confirmation_check_box_content(confirmation_checkbox_text) %>
<% end %>
@@ -0,0 +1,118 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackages
class BulkDeleteDialogComponent < ApplicationComponent
include OpTurbo::Streamable
attr_reader :work_packages
def initialize(work_packages:, back_url: nil)
super
@work_packages = work_packages
@back_url = back_url
end
private
def id = "wp-delete-dialog"
def title
I18n.t("work_packages.bulk_delete_dialog.title", count: total_count)
end
def heading
I18n.t("work_packages.bulk_delete_dialog.heading", count: total_count)
end
def description
if has_descendants?
I18n.t("work_packages.bulk_delete_dialog.description_with_children")
else
I18n.t("work_packages.bulk_delete_dialog.description")
end
end
def confirmation_checkbox_text
if has_descendants?
I18n.t("work_packages.bulk_delete_dialog.confirm_children_deletion")
else
I18n.t("text_permanent_delete_confirmation_checkbox_label")
end
end
def total_count
@total_count ||= work_packages.count + descendants_by_work_package.values.sum(&:size)
end
def multiple_projects?
projects.size > 1
end
def project_names
projects.map(&:name).join(", ")
end
def descendants_for(work_package)
(descendants_by_work_package[work_package.id] || [])
.reject { |child| work_packages.include?(child) }
end
def has_descendants?
work_packages.any? { |wp| descendants_for(wp).any? }
end
def form_action
helpers.work_packages_bulk_path(ids: work_packages.map(&:id), back_url: @back_url)
end
def projects
@projects ||= work_packages.filter_map(&:project).uniq
end
def descendants_by_work_package
@descendants_by_work_package ||= begin
hierarchies = WorkPackageHierarchy
.where(ancestor_id: work_packages.map(&:id))
.where("generations > 0")
.order(:generations, :descendant_id)
descendant_records = WorkPackage
.where(id: hierarchies.pluck(:descendant_id))
.includes(:project, :type, :status)
.index_by(&:id)
hierarchies
.group_by(&:ancestor_id)
.transform_values { |rows| rows.filter_map { |r| descendant_records[r.descendant_id] } }
end
end
end
end
@@ -0,0 +1,75 @@
<%#
-- 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.
++#
%>
<%=
render(
Primer::OpenProject::DangerDialog.new(
id:,
title:,
form_arguments: {
action: form_action,
method: :delete,
data: { turbo: false }
},
size: :medium_portrait
)
) do |dialog|
%>
<% dialog.with_confirmation_message do |message|
message.with_heading(tag: :h2) { heading }
message.with_description_content(description)
end %>
<% if has_descendants? %>
<% dialog.with_additional_details(display: :block) do %>
<% if cross_project_descendants? %>
<%= render(Primer::Alpha::Banner.new(mb: 2, scheme: :warning, icon: :alert)) do %>
<%= I18n.t("work_packages.delete_dialog.cross_project_warning", projects: all_project_names) %>
<% end %>
<% end %>
<%= render(OpPrimer::InsetBoxComponent.new) do %>
<%= render(Primer::Beta::Text.new(font_size: :small, font_weight: :bold, display: :block, mb: 2)) do %>
<%= I18n.t("work_packages.bulk_delete_dialog.children_label") %>
<% end %>
<% descendants.each do |descendant| %>
<%= render WorkPackages::InfoLineComponent.new(pl:2,
my: 1,
work_package: descendant,
show_subject: true,
show_status: false,
show_project: descendant.project != work_package.project) %>
<% end %>
<% end %>
<% end %>
<% end %>
<% dialog.with_confirmation_check_box_content(confirmation_checkbox_text) %>
<% end %>
@@ -0,0 +1,100 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackages
class DeleteDialogComponent < ApplicationComponent
include OpTurbo::Streamable
attr_reader :work_package
def initialize(work_package:, back_url: nil)
super
@work_package = work_package
@back_url = back_url
end
private
def id = "wp-delete-dialog"
def title
I18n.t("work_packages.delete_dialog.title")
end
def heading
I18n.t("work_packages.delete_dialog.heading")
end
def description
I18n.t("work_packages.delete_dialog.description", name: work_package.to_s)
end
def confirmation_checkbox_text
if has_descendants?
I18n.t("work_packages.delete_dialog.confirm_descendants_deletion")
else
I18n.t("text_permanent_delete_confirmation_checkbox_label")
end
end
def descendants
@descendants ||= WorkPackage
.joins("INNER JOIN work_package_hierarchies ON work_package_hierarchies.descendant_id = work_packages.id")
.where(work_package_hierarchies: { ancestor_id: work_package.id })
.where("work_package_hierarchies.generations > 0")
.includes(:project, :type, :status)
.order("work_package_hierarchies.generations ASC, work_packages.id ASC")
end
def has_descendants?
descendants.any?
end
def cross_project_descendants?
descendants.any? { |d| d.project != work_package.project }
end
def all_project_names
names = descendants
.filter_map(&:project)
.uniq
.reject { |p| p == work_package.project }
.map(&:name)
names
.unshift(work_package.project.name)
.join(", ")
end
def form_action
helpers.work_packages_bulk_path(ids: [work_package.id], back_url: @back_url)
end
end
end
@@ -1,9 +1,14 @@
<%=
flex_layout(flex_wrap: :wrap) do |flex|
flex_layout(flex_wrap: :wrap, **@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
render(WorkPackages::HighlightedTypeComponent.new(work_package: @work_package, font_size: :small))
end
flex.with_column(mr: 2) do
flex.with_column do
render(
Primer::Beta::Link.new(
href: url_for(controller: "/work_packages", action: "show", id: @work_package),
@@ -13,8 +18,23 @@
)
) { "##{@work_package.id}" }
end
flex.with_column do
render WorkPackages::StatusBadgeComponent.new(status: @work_package.status)
if @show_status
flex.with_column(ml: 2) do
render WorkPackages::StatusBadgeComponent.new(status: @work_package.status)
end
end
if @show_subject
flex.with_column(classes: "ellipsis", ml: 1) do
render(Primer::Beta::Text.new(font_size: @font_size)) do
if @show_project
"#{@work_package.project.name}: #{@work_package.subject}"
else
@work_package.subject
end
end
end
end
end
%>
@@ -31,10 +31,20 @@
class WorkPackages::InfoLineComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
def initialize(work_package:, font_size: :small)
def initialize(work_package:,
show_project: false,
show_subject: false,
show_status: true,
font_size: :small,
**system_arguments)
super
@work_package = work_package
@font_size = font_size
@show_project = show_project
@show_subject = show_subject
@show_status = show_status
@system_arguments = system_arguments
end
end
+1 -1
View File
@@ -166,7 +166,7 @@ class MembersController < ApplicationController
end
def members_filter_options(roles)
groups = Group.all.sort
groups = Group.visible.sort
shares = WorkPackageRole.all
status = Members::UserFilterComponent.status_param(params)
+1 -1
View File
@@ -81,7 +81,7 @@ class UsersController < ApplicationController
include PaginationHelper
def index
@groups = Group.all.sort
@groups = Group.visible.sort
@status = Users::UserFilterComponent.status_param params
@users = Users::UserFilterComponent.filter params
end
@@ -38,6 +38,18 @@ class WorkPackages::BulkController < ApplicationController
include QueriesHelper
include WorkPackages::BulkErrorMessage
include OpTurbo::ComponentStream
def delete_dialog
component =
if @work_packages.one?
WorkPackages::DeleteDialogComponent.new(work_package: @work_packages.first, back_url: params[:back_url])
else
WorkPackages::BulkDeleteDialogComponent.new(work_packages: @work_packages, back_url: params[:back_url])
end
respond_with_dialog component
end
def edit
setup_edit
@@ -70,20 +82,21 @@ class WorkPackages::BulkController < ApplicationController
end
end
def destroy
def destroy # rubocop:disable Metrics/AbcSize
if WorkPackage.cleanup_associated_before_destructing_if_required(@work_packages, current_user, params[:to_do])
destroy_work_packages(@work_packages)
respond_to do |format|
format.html do
redirect_to (project_work_packages_path(@work_packages.first.project))
redirect_back_or_default(project_work_packages_path(@work_packages.first.project),
status: :see_other)
end
format.json do
head :ok
end
end
else
redirect_to(action: :reassign, ids: @work_packages.map(&:id))
redirect_to(action: :reassign, ids: @work_packages.map(&:id), back_url: params[:back_url])
end
end
@@ -93,6 +93,6 @@ class Queries::WorkPackages::Filter::GroupFilter < Queries::WorkPackages::Filter
end
def all_groups
@all_groups ||= ::Group.all
@all_groups ||= ::Group.visible
end
end
+1 -1
View File
@@ -435,7 +435,7 @@ Rails.application.reloader.to_prepare do
wpt.permission :delete_work_packages,
{
work_packages: :destroy,
"work_packages/bulk": %i[destroy reassign]
"work_packages/bulk": %i[delete_dialog destroy reassign]
},
permissible_on: :project,
require: :member,
+2 -2
View File
@@ -154,7 +154,7 @@ fr:
connection_timeout: 'La connexion au serveur Jira a expiré : %{message}'
parse_error: 'Échec de l''analyse de la réponse de l''API Jira : %{message}'
api_error: L'API Jira a renvoyé le statut d'erreur %{status}
401_error: Jira API returned a 401 error. Your authentication token may have expired or lack the required permissions. Please ensure the token belongs to a Jira administrator.
401_error: L'API Jira a renvoyé une erreur 401. Votre jeton d'authentification peut avoir expiré ou ne pas avoir les autorisations requises. Veuillez vous assurer que le jeton appartient à un administrateur Jira.
columns:
projects: Projets
last_change: Dernière modification
@@ -2923,7 +2923,7 @@ fr:
title: Module Enterprise
plan_title: Add-on Enterprise %{plan}
plan_name: Abonnement Enterprise %{plan}
trial_text: This feature is included in your active Enterprise trial.
trial_text: Cette fonctionnalité est incluse dans votre essai en cours d'entreprise.
plan_text_html: Disponible à partir de l'abonnement %{plan_name}.
unlimited: Illimité
already_have_token: 'Vous avez déjà un jeton ? Ajoutez-le en utilisant le bouton ci-dessous pour passer à l''abonnement Enterprise réservé.
+14
View File
@@ -1417,6 +1417,20 @@ en:
no_results_title_text: There are currently no workflows.
work_packages:
delete_dialog:
title: "Delete work package"
heading: "Permanently delete this work package?"
description: 'Are you sure you want to delete the work package "%{name}"?'
confirm_descendants_deletion: "I acknowledge that ALL descendants of this work package will be recursively removed."
cross_project_warning: "Work packages from the following projects will be deleted: %{projects}"
bulk_delete_dialog:
title: "Delete %{count} work packages"
heading: "Permanently delete these %{count} work packages?"
description: "The following work packages, including children and all associated data, will permanently be deleted:"
description_with_children: "The following work packages, including child work packages, and all associated data will be permanently deleted:"
confirm_children_deletion: "I acknowledge that all selected work packages and their children will be permanently deleted."
cross_project_warning: "These work packages span multiple projects: %{projects}"
children_label: "The following children will also be deleted:"
datepicker_modal:
banner:
description:
-7
View File
@@ -1040,13 +1040,6 @@ en:
form_submit:
title: "Confirm to continue"
text: "Are you sure you want to perform this action?"
destroy_work_package:
title: "Confirm deletion of %{label}"
single_text: "Are you sure you want to delete the work package"
bulk_text: "Are you sure you want to delete the following %{label}?"
has_children: "The work package has %{childUnits}:"
confirm_deletion_children: "I acknowledge that ALL descendants of the listed work packages will be recursively removed."
deletes_children: "All child work packages and their descendants will also be recursively deleted."
destroy_time_entry:
title: "Confirm deletion of time entry"
text: "Are you sure you want to delete the following time entry?"
+1
View File
@@ -821,6 +821,7 @@ Rails.application.routes.draw do
resource :bulk, controller: "bulk", only: %i[edit update destroy] do
collection do
match :reassign, via: %i[get delete]
get :delete_dialog
end
end
end
@@ -411,6 +411,12 @@ export class PathHelperService {
return `${this.workPackagesPath(null)}/bulk`;
}
public workPackagesBulkDeleteDialogPath(ids:string[], backUrl?:string) {
const params = ids.map((id) => `ids[]=${encodeURIComponent(id)}`).join('&');
const backParam = backUrl ? `&back_url=${encodeURIComponent(backUrl)}` : '';
return `${this.workPackagesPath(null)}/bulk/delete_dialog?${params}${backParam}`;
}
public workPackagesBulkReassignmentPath() {
return `${this.workPackagesPath(null)}/bulk/reassign`;
}
@@ -272,7 +272,6 @@ import {
import { QuerySharingModalComponent } from 'core-app/shared/components/modals/share-modal/query-sharing.modal';
import { SaveQueryModalComponent } from 'core-app/shared/components/modals/save-modal/save-query.modal';
import { QuerySharingFormComponent } from 'core-app/shared/components/modals/share-modal/query-sharing-form.component';
import { WpDestroyModalComponent } from 'core-app/shared/components/modals/wp-destroy-modal/wp-destroy.modal';
import {
WorkPackageTypeStatusComponent,
} from 'core-app/features/work-packages/components/wp-type-status/wp-type-status.component';
@@ -617,7 +616,6 @@ import { WorkPackageFullViewEntryComponent } from 'core-app/features/work-packag
QuerySharingFormComponent,
QuerySharingModalComponent,
SaveQueryModalComponent,
WpDestroyModalComponent,
WorkPackageShareModalComponent,
WorkPackageReminderModalComponent,
@@ -1,89 +0,0 @@
<div
class="spot-modal"
data-indicator-name="modal"
id="wp_destroy_modal"
>
<div id="spotModalTitle" class="spot-modal--header">{{text.title}}</div>
<div class="spot-modal--body spot-container">
@if (singleWorkPackage) {
<p>
<span [textContent]="text.single_text"></span>
<strong> {{ singleWorkPackage.subject }} #{{ singleWorkPackage.id }}</strong>
<span>?</span>
</p>
@if (singleWorkPackageChildren && singleWorkPackageChildren.length > 0) {
<div>
<p class="danger-zone--warning">
<span class="icon-context icon-error"></span>
<strong [textContent]="text.warning"></strong>:
<span [textContent]="text.hasChildren(singleWorkPackage)"></span>
</p>
<ul>
@for (child of singleWorkPackageChildren; track child) {
<li class="spot-list--item">
<div class="spot-list--item-title">
#<span [textContent]="child.id"></span>
<span [textContent]="child.subject || ''"></span>
</div>
</li>
}
</ul>
<p>
<span [textContent]="text.deletesChildren"></span>
</p>
</div>
}
}
@if (workPackages.length > 1) {
<p class="danger-zone--warning">
<span class="icon-context icon-error"></span>
<strong [textContent]="text.bulk_text"></strong>
</p>
<ul>
@for (wp of workPackages; track wp) {
<li class="spot-list--item">
<div class="spot-list--item-title">
#<span [textContent]="wp.id"></span>
<span [textContent]="wp.subject"></span>
@if (children(wp).length > 0) {
<strong>(+ {{ text.childCount(wp) }})</strong>
}
</div>
</li>
}
</ul>
}
@if (mustConfirmChildren) {
<div>
<label class="form--label-with-check-box -no-ellipsis">
<div class="form--check-box-container">
<input type="checkbox"
name="confirm-children-deletion"
id="confirm-children-deletion"
[(ngModel)]="childrenDeletionConfirmed"
class="form--check-box"/>
</div>
{{ text.label_confirm_children_deletion }}
</label>
</div>
}
</div>
<div class="spot-action-bar">
<div class="spot-action-bar--right">
<button
class="button button_no-margin spot-modal--cancel-button spot-action-bar--action"
[textContent]="text.cancel"
(click)="closeMe($event)"
></button>
<button
class="button button_no-margin -danger spot-action-bar--action"
[attr.disabled]="busy || blockedDueToUnconfirmedChildren || undefined"
(click)="confirmDeletion($event)">
<svg trash-icon size="small" />
{{ text.confirm }}
</button>
</div>
</div>
</div>
@@ -1,191 +0,0 @@
//-- 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 { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service';
import { States } from 'core-app/core/states/states.service';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
OnInit,
} from '@angular/core';
import { OpModalComponent } from 'core-app/shared/components/modal/modal.component';
import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service';
import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { WorkPackageViewFocusService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service';
import { StateService } from '@uirouter/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service';
import { WorkPackageService } from 'core-app/features/work-packages/services/work-package.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { BackRoutingService } from 'core-app/features/work-packages/components/back-routing/back-routing.service';
@Component({
templateUrl: './wp-destroy.modal.html',
standalone: false,
// TODO: This component has been partially migrated to be zoneless-compatible.
// After testing, this should be updated to ChangeDetectionStrategy.OnPush.
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
changeDetection: ChangeDetectionStrategy.Default,
})
export class WpDestroyModalComponent extends OpModalComponent implements OnInit {
// When deleting multiple
public workPackages:WorkPackageResource[];
public workPackageLabel:string;
// Single work package
public singleWorkPackage:WorkPackageResource;
public singleWorkPackageChildren:WorkPackageResource[];
public busy = false;
// Need to confirm deletion when children are involved
public childrenDeletionConfirmed = false;
public text = {
label_visibility_settings: this.I18n.t('js.label_visibility_settings'),
button_save: this.I18n.t('js.modals.button_save'),
confirm: this.I18n.t('js.modals.button_delete'),
warning: this.I18n.t('js.label_warning'),
cancel: this.I18n.t('js.button_cancel'),
close: this.I18n.t('js.close_popup_title'),
label_confirm_children_deletion: this.I18n.t('js.modals.destroy_work_package.confirm_deletion_children'),
title: '',
bulk_text: '',
single_text: this.I18n.t('js.modals.destroy_work_package.single_text'),
childCount: (_wp:WorkPackageResource):string => '',
hasChildren: (_wp:WorkPackageResource):string => '',
deletesChildren: '',
};
constructor(
readonly elementRef:ElementRef,
readonly workPackageService:WorkPackageService,
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly I18n:I18nService,
readonly cdRef:ChangeDetectorRef,
readonly $state:StateService,
readonly states:States,
readonly wpTableFocus:WorkPackageViewFocusService,
readonly wpListService:WorkPackagesListService,
readonly notificationService:WorkPackageNotificationService,
readonly currentProject:CurrentProjectService,
readonly pathHelper:PathHelperService,
readonly backRoutingService:BackRoutingService,
) {
super(locals, cdRef, elementRef);
}
ngOnInit():void {
super.ngOnInit();
this.workPackages = this.locals.workPackages;
this.workPackageLabel = this.I18n.t('js.units.workPackage', { count: this.workPackages.length });
// Ugly way to provide the same view bindings as the ng-init in the previous template.
if (this.workPackages.length === 1) {
this.singleWorkPackage = this.workPackages[0];
this.singleWorkPackageChildren = this.singleWorkPackage.children;
}
this.text.title = this.I18n.t('js.modals.destroy_work_package.title', { label: this.workPackageLabel });
this.text.bulk_text = this.I18n.t('js.modals.destroy_work_package.bulk_text', {
label: this.workPackageLabel,
count: this.workPackages.length,
});
this.text.childCount = (wp:WorkPackageResource) => {
const count = this.children(wp).length;
return this.I18n.t('js.units.child_work_packages', { count });
};
this.text.hasChildren = (wp:WorkPackageResource) => {
const childUnits = this.text.childCount(wp);
return this.I18n.t('js.modals.destroy_work_package.has_children', { childUnits });
};
this.text.deletesChildren = this.I18n.t('js.modals.destroy_work_package.deletes_children');
}
public get blockedDueToUnconfirmedChildren():boolean {
return this.mustConfirmChildren && !this.childrenDeletionConfirmed;
}
public get mustConfirmChildren():boolean {
let result = false;
if (this.singleWorkPackage && this.singleWorkPackageChildren) {
result = this.singleWorkPackageChildren.length > 0;
}
return result || !!_.find(this.workPackages, (wp) => wp.children && wp.children.length > 0);
}
public confirmDeletion($event:Event):boolean {
if (this.busy || this.blockedDueToUnconfirmedChildren) {
return false;
}
this.busy = true;
this.cdRef.markForCheck();
const ids = this.workPackages
.map((el) => el.id)
.filter((id) => id !== null);
this.workPackageService.performBulkDelete(ids, true)
.then(() => {
this.busy = false;
this.cdRef.markForCheck();
this.closeMe($event);
this.wpTableFocus.clear('Clearing after destroying work packages');
if (this.$state.current.data?.baseRoute) {
this.backRoutingService.goBack(true);
} else {
const projectIdentifier = this.currentProject.identifier;
window.location.href = this.pathHelper.workPackagesPath(projectIdentifier) + window.location.search;
}
})
.catch(() => {
this.busy = false;
this.cdRef.markForCheck();
});
return false;
}
public children(workPackage:WorkPackageResource) {
if (workPackage.hasOwnProperty('children')) {
return workPackage.children;
}
return [];
}
}
@@ -12,18 +12,17 @@ import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op
import {
PERMITTED_CONTEXT_MENU_ACTIONS,
} from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-static-context-menu-actions';
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
import { CopyToClipboardService } from 'core-app/shared/components/copy-to-clipboard/copy-to-clipboard.service';
import {
WorkPackageAction,
} from 'core-app/features/work-packages/components/wp-table/context-menu-helper/wp-context-menu-helper.service';
import { WpDestroyModalComponent } from 'core-app/shared/components/modals/wp-destroy-modal/wp-destroy.modal';
import { WorkPackageAuthorization } from 'core-app/features/work-packages/services/work-package-authorization.service';
import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { TimeEntryTimerService } from 'core-app/shared/components/time_entries/services/time-entry-timer.service';
import { TimeEntryResource } from 'core-app/features/hal/resources/time-entry-resource';
import { DeviceService } from 'core-app/core/browser/device.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
@@ -41,10 +40,10 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger
readonly injector = inject(Injector);
readonly PathHelper = inject(PathHelperService);
readonly elementRef = inject(ElementRef);
readonly opModalService = inject(OpModalService);
readonly turboRequests = inject(TurboRequestsService);
readonly apiV3Service = inject(ApiV3Service);
readonly authorisationService = inject(AuthorisationService);
readonly currentProject = inject(CurrentProjectService);
readonly timeEntryService = inject(TimeEntryTimerService);
protected copyToClipboardService = inject(CopyToClipboardService);
protected deviceService = inject(DeviceService);
@@ -96,9 +95,18 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger
window.location.href = `${this.PathHelper.workPackageCopyPath(this.workPackage.project.identifier, this.workPackage.id)}`;
}
break;
case 'delete':
this.opModalService.show(WpDestroyModalComponent, this.injector, { workPackages: [this.workPackage] });
case 'delete': {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const currentBaseRoute = this.$state.current.data?.baseRoute as string | undefined;
const backUrl = currentBaseRoute
? this.$state.href(currentBaseRoute)
: this.PathHelper.workPackagesPath(this.currentProject.identifier ?? null);
void this.turboRequests.request(
this.PathHelper.workPackagesBulkDeleteDialogPath([this.workPackage.id!], backUrl),
{ method: 'GET' },
);
break;
}
case 'log_time':
void this.turboRequests.request(this.PathHelper.timeEntryWorkPackageDialog(this.workPackage.id!), { method: 'GET' });
break;
@@ -20,12 +20,10 @@ import {
import {
PERMITTED_CONTEXT_MENU_ACTIONS,
} from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-static-context-menu-actions';
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
import { StateService } from '@uirouter/core';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { CopyToClipboardService } from 'core-app/shared/components/copy-to-clipboard/copy-to-clipboard.service';
import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper';
import { WpDestroyModalComponent } from 'core-app/shared/components/modals/wp-destroy-modal/wp-destroy.modal';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service';
@@ -40,8 +38,6 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler {
@InjectField() protected wpRelationsHierarchyService:WorkPackageRelationsHierarchyService;
@InjectField() protected opModalService:OpModalService;
@InjectField() protected $state!:StateService;
@InjectField() protected wpTableSelection:WorkPackageViewSelectionService;
@@ -152,7 +148,9 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler {
private deleteSelectedWorkPackages() {
const selected = this.getSelectedWorkPackages();
this.opModalService.show(WpDestroyModalComponent, this.injector, { workPackages: selected });
const ids = selected.map((wp) => wp.id).filter((id) => id !== null);
const backUrl = this.$state.href(this.baseRoute as string) || this.pathHelper.workPackagesPath(this.currentProject.identifier ?? null);
void this.turboRequests.request(this.pathHelper.workPackagesBulkDeleteDialogPath(ids, backUrl), { method: 'GET' });
}
private editSelectedWorkPackages(link:any) {
@@ -32,8 +32,16 @@ module OpenProject::WorkPackages
# @logical_path OpenProject/WorkPackages
class InfoLineComponentPreview < ViewComponent::Preview
# See the [component documentation](/lookbook/pages/components/work_package_info_line) for more details.
def playground
render(WorkPackages::InfoLineComponent.new(work_package: WorkPackage.visible.first))
# @param show_project [Boolean]
# @param show_subject [Boolean]
# @param show_status [Boolean]
# @param font_size [Symbol] select [small, normal]
def playground(show_project: false, show_subject: false, show_status: true, font_size: :small)
render(WorkPackages::InfoLineComponent.new(work_package: WorkPackage.visible.first,
show_project:,
show_subject:,
show_status:,
font_size:))
end
end
end
+12 -12
View File
@@ -42,9 +42,9 @@ fr:
start_date: Date
start_time: Heure de début
start_time_hour: Heure de début
end_time: End time
template: Template
notify: Send notifications
end_time: Heure de fin
template: Modèle
notify: Envoyer les notifications
sharing: Partage
meeting_agenda_item:
title: Titre
@@ -54,13 +54,13 @@ fr:
presenter: Responsable
item_type: Type
position: Position
lock_version: Lock version
lock_version: Verrouiller la version
notes: Notes
meeting_section:
title: Titre
position: Position
recurring_meeting:
title: Title
title: Titre
frequency: Fréquence
interval: Intervalle
start_date: Commence le
@@ -69,10 +69,10 @@ fr:
end_after: La série de réunions se termine
end_date: Échéance
iterations: Occurrences
time_zone: Time zone
location: Location
duration: Duration
notify: Send notifications
time_zone: Fuseau horaire
location: Emplacement
duration: Durée
notify: Envoyer les notifications
recurring_meeting_interim_response:
start_time: Heure de début
meeting_participant:
@@ -435,9 +435,9 @@ fr:
meeting_not_found: Réunion non trouvée pour l'UID donné.
widgets:
blankslate:
heading: No upcoming meetings
description: Upcoming meetings where you are the organizer or a participant will appear here.
view_details: View all meetings
heading: Aucune réunion à venir
description: Les prochaines réunions où vous êtes l'organisateur ou un participant apparaîtront ici.
view_details: Voir toutes les réunions
meeting_section:
untitled_title: Section sans titre
delete_confirmation: Supprimer la section supprimera également tous les éléments de l'ordre du jour qui s'y rapportent. Voulez-vous vraiment continuer ?
+3 -3
View File
@@ -720,9 +720,9 @@ en:
text_exit_draft_mode_dialog_template_subtitle: "You cannot return to draft mode after this."
label_meeting_template_sharing: "Sharing"
label_meeting_template_sharing_none: "Only this project"
label_meeting_template_sharing_descendants: "Subprojects"
label_meeting_template_sharing_system: "All projects"
label_meeting_template_sharing_none: "Only with this project"
label_meeting_template_sharing_descendants: "With subprojects"
label_meeting_template_sharing_system: "With all projects"
text_meeting_template_sharing_description: "This template can be shared with subprojects or other projects in this instance. Only the agenda items and attachments will be copied."
text_meeting_not_editable_anymore: "This meeting is not editable anymore."
@@ -478,7 +478,7 @@ module Pages::Meetings
end
def edit_agenda_item(item, save: true, wait_for_reference_update: false, &)
select_action item, "Edit"
wait_for_turbo_stream { select_action item, "Edit" }
expect_item_edit_form(item)
reference_value = meeting_reference_value
page.within("#meeting-agenda-items-form-component-#{item.id}") do
@@ -8,7 +8,7 @@ module ::TwoFactorAuthentication
before_action :ensure_enabled_2fa
# Locate the user we're editing
prepend_before_action :find_user
before_action :find_user
before_action :find_device, only: %i[confirm make_default destroy]
@@ -0,0 +1,37 @@
# frozen_string_literal: true
require_relative "../spec_helper"
# Regression test: find_user in BaseController used prepend_before_action, which ran it
# before ApplicationController#user_setup could set User.current from the session.
# User.visible defaults to User.current, so without User.current set to the admin,
# the target user was not found (404).
RSpec.describe "Admin deleting another user's 2FA device", :js,
with_settings: {
plugin_openproject_two_factor_authentication: { "active_strategies" => %i[developer totp] }
} do
let(:dialog) { Components::PasswordConfirmationDialog.new }
let(:admin_password) { "adminadmin!" * 2 }
let(:admin) { create(:admin, password: admin_password, password_confirmation: admin_password) }
let(:other_user) { create(:user) }
let!(:device) { create(:two_factor_authentication_device_totp, user: other_user, default: false, active: true) }
before do
# Use a real browser login so the session is established via the normal auth flow
# (user_setup callback), not via RequestStore stubbing. This exposes the
# prepend_before_action ordering bug where find_user ran before User.current was set.
login_with(admin.login, admin_password)
end
it "deletes the device" do
visit edit_user_path(other_user, tab: :two_factor_authentication)
expect(page).to have_css(".mobile-otp--two-factor-device-row", count: 1)
find(".two-factor--delete-button").click
dialog.confirm_flow_with(admin_password)
expect(page).to have_css(".generic-table--empty-row")
expect(other_user.otp_devices.reload).to be_empty
end
end
+7 -7
View File
@@ -22,23 +22,23 @@ fr:
one: Lien de la page de relation
other: Liens de la page de relation
wikis/xwiki_provider: Fournisseur XWiki
permission_manage_wiki_page_links: Manage Wiki Page Links
permission_view_wiki_page_links: View Wiki Page Links
permission_manage_wiki_page_links: Gérer les liens de la page Wiki
permission_view_wiki_page_links: Gérer les liens de la page Wiki
project_module_wiki_platforms: Fournisseurs de wiki
wikis:
buttons:
save_and_continue: Enregistrer et continuer
wiki_page: Wiki page
wiki_page: Page wiki
inline_page_links_component:
empty_heading: Aucun lien intégré dans la page
empty_text: Les liens vers les pages wiki dans la description du lot de travaux apparaîtront automatiquement ici.
heading: Liens intégrés dans la page
page_link_component:
remove: Remove page link
remove: Supprimer le lien de la page
relation_page_links_component:
empty_heading: No related pages
empty_text: Manually add links to other related wiki pages.
heading: Related pages
empty_heading: Pas de pages liées
empty_text: Ajouter manuellement des liens vers d'autres pages wiki liées.
heading: Pages liées
admin:
wiki_providers:
index_description: Ajoutez un service wiki externe pour lier les lots de travaux à des pages wiki existantes ou en créer de nouvelles directement à partir d'OpenProject.
@@ -211,6 +211,26 @@ RSpec.describe MembersController do
end
end
describe "#index" do
let(:role) { create(:project_role, permissions: [:manage_members]) }
let!(:member) { create(:member, project:, user:, roles: [role]) }
let!(:visible_group) { create(:group, members: [user]) }
let!(:hidden_group) { create(:group) }
before { login_as(user) }
it "only includes groups the user is a member of in the filter options" do
get :index, params: { project_id: project.id }
expect(response).to be_successful
groups = assigns(:members_filter_options)[:groups]
expect(groups).to include(visible_group)
expect(groups).not_to include(hidden_group)
end
end
describe "#create with reduced visibility" do
let(:project_permissions) { %i[manage_members invite_members_by_email] }
let!(:other_project) { create(:project) }
@@ -33,7 +33,7 @@ require "spec_helper"
RSpec.describe "Delete work package", :js do
let(:user) { create(:admin) }
let(:context_menu) { Components::WorkPackages::ContextMenu.new }
let(:destroy_modal) { Components::WorkPackages::DestroyModal.new }
let(:destroy_modal) { Components::WorkPackages::DestroyModal.new(bulk_mode: true) }
before do
login_as(user)
@@ -85,7 +85,7 @@ RSpec.describe "Delete work package", :js do
context_menu.open_for(wp1)
context_menu.choose("Bulk delete")
destroy_modal.confirm_children_deletion
destroy_modal.expect_listed(wp1, wp2, wp_child)
destroy_modal.confirm_deletion
loading_indicator_saveguard
@@ -59,7 +59,7 @@ RSpec.describe Queries::WorkPackages::Filter::GroupFilter do
describe "#allowed_values" do
before do
allow(Group)
.to receive(:all)
.to receive(:visible)
.and_return [group]
end
@@ -81,7 +81,7 @@ RSpec.describe Queries::WorkPackages::Filter::GroupFilter do
before do
allow(Group)
.to receive(:all)
.to receive(:visible)
.and_return([group, group2])
instance.values = [group2.id.to_s]
@@ -74,10 +74,9 @@ module Components
def choose_delete_and_confirm_deletion
choose "Delete"
# only handle the case where the modal does _not_ ask for descendants deletion confirmation
within_modal(I18n.t("js.modals.destroy_work_package.title", label: "work package")) do
click_button "Delete"
end
dialog = ::Components::WorkPackages::DestroyModal.new
dialog.confirm_deletion
end
def expect_no_options(*options)
@@ -35,39 +35,36 @@ module Components
include Capybara::RSpecMatchers
include RSpec::Matchers
def container
"#wp_destroy_modal"
def initialize(bulk_mode: false)
@bulk_mode = bulk_mode
end
def expect_listed(*wps)
page.within(container) do
if wps.length == 1
wp = wps.first
expect(page).to have_css("strong", text: "#{wp.subject} ##{wp.id}")
else
expect(page).to have_css(".danger-zone--warning",
text: "Are you sure you want to delete the following work packages?")
wps.each do |wp|
expect(page).to have_css("li", text: "##{wp.id}#{wp.subject}")
end
def dialog_css_selector
"dialog#wp-delete-dialog"
end
def within_dialog(&)
within(dialog_css_selector, &)
end
def expect_listed(*work_packages)
within_dialog do
work_packages.each do |work_package|
expect(page).to have_text(work_package.subject)
end
end
end
def confirm_children_deletion
page.within(container) do
check "confirm-children-deletion"
end
end
def confirm_deletion
page.within(container) do
click_button "Delete"
within_dialog do
check "I understand that this deletion cannot be reversed"
expect(page).to have_button "Delete permanently", disabled: false
click_button "Delete permanently"
end
end
def cancel_deletion
page.within(container) do
within_dialog do
click_button "Cancel"
end
end
+1 -1
View File
@@ -41,7 +41,7 @@ RSpec.describe "users/index" do
assign(:users, User.where(id: [admin.id, user.id]))
assign(:status, "all")
assign(:groups, Group.all)
assign(:groups, Group.visible)
without_partial_double_verification do
allow(view).to receive_messages(current_user: admin, controller_name: "users", action_name: "index")