Delete dialog primer

This commit is contained in:
Oliver Günther
2026-04-07 14:25:29 +02:00
parent 85769ac897
commit 2898a1d0ff
20 changed files with 473 additions and 333 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,117 @@
# 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:)
super
@work_packages = work_packages
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))
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,99 @@
# 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:)
super
@work_package = work_package
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])
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
@@ -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)
else
WorkPackages::BulkDeleteDialogComponent.new(work_packages: @work_packages)
end
respond_with_dialog component
end
def edit
setup_edit
@@ -76,7 +88,9 @@ class WorkPackages::BulkController < ApplicationController
respond_to do |format|
format.html do
redirect_to (project_work_packages_path(@work_packages.first.project))
redirect_back_or_to(project_work_packages_path(@work_packages.first.project),
status: :see_other,
allow_other_host: false)
end
format.json do
head :ok
+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,
+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
@@ -1046,13 +1046,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,11 @@ export class PathHelperService {
return `${this.workPackagesPath(null)}/bulk`;
}
public workPackagesBulkDeleteDialogPath(ids:string[]) {
const params = ids.map((id) => `ids[]=${encodeURIComponent(id)}`).join('&');
return `${this.workPackagesPath(null)}/bulk/delete_dialog?${params}`;
}
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,12 +12,10 @@ 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';
@@ -41,7 +39,6 @@ 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);
@@ -97,7 +94,10 @@ export class WorkPackageSingleContextMenuDirective extends OpContextMenuTrigger
}
break;
case 'delete':
this.opModalService.show(WpDestroyModalComponent, this.injector, { workPackages: [this.workPackage] });
void this.turboRequests.request(
this.PathHelper.workPackagesBulkDeleteDialogPath([this.workPackage.id!]),
{ method: 'GET' },
);
break;
case 'log_time':
void this.turboRequests.request(this.PathHelper.timeEntryWorkPackageDialog(this.workPackage.id!), { method: 'GET' });
@@ -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,8 @@ 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) as string[];
void this.turboRequests.request(this.pathHelper.workPackagesBulkDeleteDialogPath(ids), { method: 'GET' });
}
private editSelectedWorkPackages(link:any) {
@@ -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
@@ -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