From f8e1db49874762455041974391be3ccc51c1a5d3 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Thu, 18 Aug 2022 14:04:59 +0200 Subject: [PATCH 1/4] 93914: Add the ability to delete processes --- .../detail/process-detail.component.html | 92 ++++++++--- .../detail/process-detail.component.spec.ts | 60 ++++++- .../detail/process-detail.component.ts | 51 +++++- .../process-bulk-delete.service.spec.ts | 149 ++++++++++++++++++ .../overview/process-bulk-delete.service.ts | 118 ++++++++++++++ .../overview/process-overview.component.html | 58 ++++++- .../process-overview.component.spec.ts | 104 +++++++++++- .../overview/process-overview.component.ts | 60 ++++++- src/assets/i18n/en.json5 | 35 ++++ 9 files changed, 685 insertions(+), 42 deletions(-) create mode 100644 src/app/process-page/overview/process-bulk-delete.service.spec.ts create mode 100644 src/app/process-page/overview/process-bulk-delete.service.ts diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 995e56e081..ae3418eafa 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -1,53 +1,99 @@
-

{{'process.detail.title' | translate:{id: process?.processId, name: process?.scriptName} }}

-
- -
+

{{'process.detail.title' | translate:{ + id: process?.processId, + name: process?.scriptName + } }}

{{ process?.scriptName }}
- +
{{ argument?.name }} {{ argument?.value }}
- - - {{getFileName(file)}} - ({{(file?.sizeBytes) | dsFileSize }}) - + + + {{getFileName(file)}} + ({{(file?.sizeBytes) | dsFileSize }}) +
- +
{{ process.startTime | date:dateFormat:'UTC' }}
- +
{{ process.endTime | date:dateFormat:'UTC' }}
- +
{{ process.processStatus }}
- - -
{{ (outputLogs$ | async) }}
-

+ {{ 'process.detail.logs.button' | translate }} + + +

{{ (outputLogs$ | async) }}
+

- {{ 'process.detail.logs.none' | translate }} -

+ {{ 'process.detail.logs.none' | translate }} +

+
+ + + +
+ + + +
+ + + + + + +
+ +
+ diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index 4a85c75f81..53e65e62eb 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/src/app/process-page/detail/process-detail.component.spec.ts @@ -19,15 +19,23 @@ import { RouterTestingModule } from '@angular/router/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ProcessDetailFieldComponent } from './process-detail-field/process-detail-field.component'; import { Process } from '../processes/process.model'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { of as observableOf } from 'rxjs'; import { By } from '@angular/platform-browser'; import { FileSizePipe } from '../../shared/utils/file-size-pipe'; import { Bitstream } from '../../core/shared/bitstream.model'; import { ProcessDataService } from '../../core/data/processes/process-data.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { getProcessListRoute } from '../process-page-routing.paths'; describe('ProcessDetailComponent', () => { let component: ProcessDetailComponent; @@ -44,6 +52,11 @@ describe('ProcessDetailComponent', () => { let processOutput; + let modalService; + let notificationsService; + + let router; + function init() { processOutput = 'Process Started'; process = Object.assign(new Process(), { @@ -93,7 +106,8 @@ describe('ProcessDetailComponent', () => { } }); processService = jasmine.createSpyObj('processService', { - getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)) + getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)), + delete: createSuccessfulRemoteDataObject$(null) }); bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { findByHref: createSuccessfulRemoteDataObject$(logBitstream) @@ -104,13 +118,23 @@ describe('ProcessDetailComponent', () => { httpClient = jasmine.createSpyObj('httpClient', { get: observableOf(processOutput) }); + + modalService = jasmine.createSpyObj('modalService', { + open: {} + }); + + notificationsService = new NotificationsServiceStub(); + + router = jasmine.createSpyObj('router', { + navigateByUrl:{} + }); } beforeEach(waitForAsync(() => { init(); TestBed.configureTestingModule({ declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + imports: [TranslateModule.forRoot()], providers: [ { provide: ActivatedRoute, @@ -121,6 +145,9 @@ describe('ProcessDetailComponent', () => { { provide: DSONameService, useValue: nameService }, { provide: AuthService, useValue: new AuthServiceMock() }, { provide: HttpClient, useValue: httpClient }, + { provide: NgbModal, useValue: modalService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: Router, useValue: router }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -207,4 +234,29 @@ describe('ProcessDetailComponent', () => { }); }); + describe('openDeleteModal', () => { + it('should open the modal', () => { + component.openDeleteModal({}); + expect(modalService.open).toHaveBeenCalledWith({}); + }); + }); + + describe('deleteProcess', () => { + it('should delete the process and navigate back to the overview page on success', () => { + component.deleteProcess(process); + + expect(processService.delete).toHaveBeenCalledWith(process.processId); + expect(notificationsService.success).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessListRoute()); + }); + it('should delete the process and not navigate on error', () => { + (processService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + component.deleteProcess(process); + + expect(processService.delete).toHaveBeenCalledWith(process.processId); + expect(notificationsService.error).toHaveBeenCalled(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + }); diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index 8a05288eb2..f9151d27b8 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -12,8 +12,9 @@ import { RemoteData } from '../../core/data/remote-data'; import { Bitstream } from '../../core/shared/bitstream.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { - getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteData + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { AlertType } from '../../shared/alert/aletr-type'; @@ -21,6 +22,10 @@ import { hasValue } from '../../shared/empty.util'; import { ProcessStatus } from '../processes/process-status.model'; import { Process } from '../processes/process.model'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { getProcessListRoute } from '../process-page-routing.paths'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'ds-process-detail', @@ -71,6 +76,11 @@ export class ProcessDetailComponent implements OnInit { */ dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ'; + /** + * Reference to NgbModal + */ + protected modalRef: NgbModalRef; + constructor(protected route: ActivatedRoute, protected router: Router, protected processService: ProcessDataService, @@ -78,7 +88,11 @@ export class ProcessDetailComponent implements OnInit { protected nameService: DSONameService, private zone: NgZone, protected authService: AuthService, - protected http: HttpClient) { + protected http: HttpClient, + protected modalService: NgbModal, + protected notificationsService: NotificationsService, + protected translateService: TranslateService + ) { } /** @@ -172,4 +186,35 @@ export class ProcessDetailComponent implements OnInit { || process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString())); } + /** + * Delete the current process + * @param process + */ + deleteProcess(process: Process) { + this.processService.delete(process.processId).pipe( + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('process.detail.delete.success')); + this.router.navigateByUrl(getProcessListRoute()); + } else { + this.notificationsService.error(this.translateService.get('process.detail.delete.error')); + } + }); + } + + /** + * Open a given modal. + * @param content - the modal content. + */ + openDeleteModal(content) { + this.modalRef = this.modalService.open(content); + } + /** + * Close the modal. + */ + closeModal() { + this.modalRef.close(); + } + } diff --git a/src/app/process-page/overview/process-bulk-delete.service.spec.ts b/src/app/process-page/overview/process-bulk-delete.service.spec.ts new file mode 100644 index 0000000000..5139048dd1 --- /dev/null +++ b/src/app/process-page/overview/process-bulk-delete.service.spec.ts @@ -0,0 +1,149 @@ +import { ProcessBulkDeleteService } from './process-bulk-delete.service'; +import { waitForAsync } from '@angular/core/testing'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; + +describe('ProcessBulkDeleteService', () => { + + let service: ProcessBulkDeleteService; + let processDataService; + let notificationsService; + let mockTranslateService; + + beforeEach(waitForAsync(() => { + processDataService = jasmine.createSpyObj('processDataService', { + delete: createSuccessfulRemoteDataObject$(null) + }); + notificationsService = new NotificationsServiceStub(); + mockTranslateService = getMockTranslateService(); + service = new ProcessBulkDeleteService(processDataService, notificationsService, mockTranslateService); + })); + + describe('toggleDelete', () => { + it('should add a new value to the processesToDelete list when not yet present', () => { + service.toggleDelete('test-id-1'); + service.toggleDelete('test-id-2'); + + expect(service.processesToDelete).toEqual(['test-id-1', 'test-id-2']); + }); + it('should remove a value from the processesToDelete list when already present', () => { + service.toggleDelete('test-id-1'); + service.toggleDelete('test-id-2'); + + expect(service.processesToDelete).toEqual(['test-id-1', 'test-id-2']); + + service.toggleDelete('test-id-1'); + expect(service.processesToDelete).toEqual(['test-id-2']); + }); + }); + + describe('isToBeDeleted', () => { + it('should return true when the provided process id is present in the list', () => { + service.toggleDelete('test-id-1'); + service.toggleDelete('test-id-2'); + + expect(service.isToBeDeleted('test-id-1')).toBeTrue(); + }); + it('should return false when the provided process id is not present in the list', () => { + service.toggleDelete('test-id-1'); + service.toggleDelete('test-id-2'); + + expect(service.isToBeDeleted('test-id-3')).toBeFalse(); + }); + }); + + describe('clearAllProcesses', () => { + it('should clear the list of to be deleted processes', () => { + service.toggleDelete('test-id-1'); + service.toggleDelete('test-id-2'); + + expect(service.processesToDelete).toEqual(['test-id-1', 'test-id-2']); + + service.clearAllProcesses(); + expect(service.processesToDelete).toEqual([]); + }); + }); + describe('getAmountOfSelectedProcesses', () => { + it('should return the amount of the currently selected processes for deletion', () => { + service.toggleDelete('test-id-1'); + service.toggleDelete('test-id-2'); + + expect(service.getAmountOfSelectedProcesses()).toEqual(2); + }); + }); + describe('isProcessing$', () => { + it('should return a behavior subject containing whether a delete is currently processing or not', () => { + const result = service.isProcessing$(); + expect(result.getValue()).toBeFalse(); + + result.next(true); + expect(result.getValue()).toBeTrue(); + }); + }); + describe('hasSelected', () => { + it('should return if the list of selected processes has values', () => { + expect(service.hasSelected()).toBeFalse(); + + service.toggleDelete('test-id-1'); + service.toggleDelete('test-id-2'); + + expect(service.hasSelected()).toBeTrue(); + }); + }); + describe('deleteSelectedProcesses', () => { + it('should delete all selected processes, show an error for each failed one and a notification at the end with the amount of succeeded deletions', () => { + (processDataService.delete as jasmine.Spy).and.callFake((processId: string) => { + if (processId.includes('error')) { + return createFailedRemoteDataObject$(); + } else { + return createSuccessfulRemoteDataObject$(null); + } + }); + + service.toggleDelete('test-id-1'); + service.toggleDelete('test-id-2'); + service.toggleDelete('error-id-3'); + service.toggleDelete('test-id-4'); + service.toggleDelete('error-id-5'); + service.toggleDelete('error-id-6'); + service.toggleDelete('test-id-7'); + + + service.deleteSelectedProcesses(); + + expect(processDataService.delete).toHaveBeenCalledWith('test-id-1'); + + + expect(processDataService.delete).toHaveBeenCalledWith('test-id-2'); + + + expect(processDataService.delete).toHaveBeenCalledWith('error-id-3'); + expect(notificationsService.error).toHaveBeenCalled(); + expect(mockTranslateService.get).toHaveBeenCalledWith('process.bulk.delete.error.body', {processId: 'error-id-3'}); + + + expect(processDataService.delete).toHaveBeenCalledWith('test-id-4'); + + + expect(processDataService.delete).toHaveBeenCalledWith('error-id-5'); + expect(notificationsService.error).toHaveBeenCalled(); + expect(mockTranslateService.get).toHaveBeenCalledWith('process.bulk.delete.error.body', {processId: 'error-id-5'}); + + + expect(processDataService.delete).toHaveBeenCalledWith('error-id-6'); + expect(notificationsService.error).toHaveBeenCalled(); + expect(mockTranslateService.get).toHaveBeenCalledWith('process.bulk.delete.error.body', {processId: 'error-id-6'}); + + + expect(processDataService.delete).toHaveBeenCalledWith('test-id-7'); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(mockTranslateService.get).toHaveBeenCalledWith('process.bulk.delete.success', {count: 4}); + + expect(service.processesToDelete).toEqual(['error-id-3', 'error-id-5', 'error-id-6']); + + + }); + }); +}); diff --git a/src/app/process-page/overview/process-bulk-delete.service.ts b/src/app/process-page/overview/process-bulk-delete.service.ts new file mode 100644 index 0000000000..c0943fb615 --- /dev/null +++ b/src/app/process-page/overview/process-bulk-delete.service.ts @@ -0,0 +1,118 @@ +import { Process } from '../processes/process.model'; +import { Injectable } from '@angular/core'; +import { ProcessDataService } from '../../core/data/processes/process-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { isNotEmpty } from '../../shared/empty.util'; +import { BehaviorSubject, count, from } from 'rxjs'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { concatMap, filter, tap } from 'rxjs/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Service to facilitate removing processes in bulk. + */ +export class ProcessBulkDeleteService { + + /** + * Array to track the processes to be deleted + */ + processesToDelete: string[] = []; + + /** + * Behavior subject to track whether the delete is processing + * @protected + */ + protected isProcessingBehaviorSubject: BehaviorSubject = new BehaviorSubject(false); + + constructor( + protected processDataService: ProcessDataService, + protected notificationsService: NotificationsService, + protected translateService: TranslateService + ) { + } + + /** + * Add or remove a process id to/from the list + * If the id is already present it will be removed, otherwise it will be added. + * + * @param processId - The process id to add or remove + */ + toggleDelete(processId: string) { + if (this.isToBeDeleted(processId)) { + this.processesToDelete.splice(this.processesToDelete.indexOf(processId), 1); + } else { + this.processesToDelete.push(processId); + } + } + + /** + * Checks if the provided process id is present in the to be deleted list + * @param processId + */ + isToBeDeleted(processId: string) { + return this.processesToDelete.includes(processId); + } + + /** + * Clear the list of processes to be deleted + */ + clearAllProcesses() { + this.processesToDelete.splice(0); + } + + /** + * Get the amount of processes selected for deletion + */ + getAmountOfSelectedProcesses() { + return this.processesToDelete.length; + } + + /** + * Returns a behavior subject to indicate whether the bulk delete is processing + */ + isProcessing$() { + return this.isProcessingBehaviorSubject; + } + + /** + * Returns whether there currently are values selected for deletion + */ + hasSelected(): boolean { + return isNotEmpty(this.processesToDelete); + } + + /** + * Delete all selected processes one by one + * When the deletion for a process fails, an error notification will be shown with the process id, + * but it will continue deleting the other processes. + * At the end it will show a notification stating the amount of successful deletes + * The successfully deleted processes will be removed from the list of selected values, the failed ones will be retained. + */ + deleteSelectedProcesses() { + this.isProcessingBehaviorSubject.next(true); + + from([...this.processesToDelete]).pipe( + concatMap((processId) => { + return this.processDataService.delete(processId).pipe( + getFirstCompletedRemoteData(), + tap((rd: RemoteData) => { + if (rd.hasFailed) { + this.notificationsService.error(this.translateService.get('process.bulk.delete.error.head'), this.translateService.get('process.bulk.delete.error.body', {processId: processId})); + } else { + this.toggleDelete(processId); + } + }) + ); + }), + filter((rd: RemoteData) => rd.hasSucceeded), + count(), + ).subscribe((value) => { + this.notificationsService.success(this.translateService.get('process.bulk.delete.success', {count: value})); + this.isProcessingBehaviorSubject.next(false); + }); + } +} diff --git a/src/app/process-page/overview/process-overview.component.html b/src/app/process-page/overview/process-overview.component.html index 7d3f15f074..5bf5fee79f 100644 --- a/src/app/process-page/overview/process-overview.component.html +++ b/src/app/process-page/overview/process-overview.component.html @@ -1,7 +1,19 @@

{{'process.overview.title' | translate}}

- +
+
+ + + +
{{'process.overview.table.start' | translate}} {{'process.overview.table.finish' | translate}} {{'process.overview.table.status' | translate}} + {{'process.overview.table.actions' | translate}} - + {{process.processId}} {{process.scriptName}} {{ePersonName}} {{process.startTime | date:dateFormat:'UTC'}} {{process.endTime | date:dateFormat:'UTC'}} {{process.processStatus}} + + +
+ + + +
+ + + + +
+ + +
+ diff --git a/src/app/process-page/overview/process-overview.component.spec.ts b/src/app/process-page/overview/process-overview.component.spec.ts index c147ed00ca..94071c0e59 100644 --- a/src/app/process-page/overview/process-overview.component.spec.ts +++ b/src/app/process-page/overview/process-overview.component.spec.ts @@ -1,5 +1,5 @@ import { ProcessOverviewComponent } from './process-overview.component'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { VarDirective } from '../../shared/utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; @@ -13,11 +13,11 @@ import { ProcessStatus } from '../processes/process-status.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { PaginationService } from '../../core/pagination/pagination.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../core/data/find-list-options.model'; import { DatePipe } from '@angular/common'; +import { BehaviorSubject } from 'rxjs'; +import { ProcessBulkDeleteService } from './process-bulk-delete.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; describe('ProcessOverviewComponent', () => { let component: ProcessOverviewComponent; @@ -30,6 +30,9 @@ describe('ProcessOverviewComponent', () => { let processes: Process[]; let ePerson: EPerson; + let processBulkDeleteService; + let modalService; + const pipe = new DatePipe('en-US'); function init() { @@ -80,6 +83,29 @@ describe('ProcessOverviewComponent', () => { }); paginationService = new PaginationServiceStub(); + + processBulkDeleteService = jasmine.createSpyObj('processBulkDeleteService', { + clearAllProcesses: {}, + deleteSelectedProcesses: {}, + isProcessing$: new BehaviorSubject(false), + hasSelected: true, + isToBeDeleted: true, + toggleDelete: {}, + getAmountOfSelectedProcesses: 5 + + }); + + (processBulkDeleteService.isToBeDeleted as jasmine.Spy).and.callFake((id) => { + if (id === 2) { + return true; + } else { + return false; + } + }); + + modalService = jasmine.createSpyObj('modalService', { + open: {} + }); } beforeEach(waitForAsync(() => { @@ -90,7 +116,9 @@ describe('ProcessOverviewComponent', () => { providers: [ { provide: ProcessDataService, useValue: processService }, { provide: EPersonDataService, useValue: ePersonService }, - { provide: PaginationService, useValue: paginationService } + { provide: PaginationService, useValue: paginationService }, + { provide: ProcessBulkDeleteService, useValue: processBulkDeleteService }, + { provide: NgbModal, useValue: modalService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -154,5 +182,71 @@ describe('ProcessOverviewComponent', () => { expect(el.textContent).toContain(processes[index].processStatus); }); }); + it('should display a delete button in the seventh column', () => { + rowElements.forEach((rowElement, index) => { + const el = rowElement.query(By.css('td:nth-child(7)')); + expect(el.nativeElement.innerHTML).toContain('fas fa-trash'); + + el.query(By.css('button')).triggerEventHandler('click', null); + expect(processBulkDeleteService.toggleDelete).toHaveBeenCalledWith(processes[index].processId); + }); + }); + it('should indicate a row that has been selected for deletion', () => { + const deleteRow = fixture.debugElement.query(By.css('.table-danger')); + expect(deleteRow.nativeElement.innerHTML).toContain('/processes/' + processes[1].processId); + }); + }); + + describe('overview buttons', () => { + it('should show a button to clear selected processes when there are selected processes', () => { + const clearButton = fixture.debugElement.query(By.css('.btn-primary')); + expect(clearButton.nativeElement.innerHTML).toContain('process.overview.delete.clear'); + + clearButton.triggerEventHandler('click', null); + expect(processBulkDeleteService.clearAllProcesses).toHaveBeenCalled(); + }); + it('should not show a button to clear selected processes when there are no selected processes', () => { + (processBulkDeleteService.hasSelected as jasmine.Spy).and.returnValue(false); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css('.btn-primary')); + expect(clearButton).toBeNull(); + }); + it('should show a button to open the delete modal when there are selected processes', () => { + spyOn(component, 'openDeleteModal'); + + const deleteButton = fixture.debugElement.query(By.css('.btn-danger')); + expect(deleteButton.nativeElement.innerHTML).toContain('process.overview.delete'); + + deleteButton.triggerEventHandler('click', null); + expect(component.openDeleteModal).toHaveBeenCalled(); + }); + it('should not show a button to clear selected processes when there are no selected processes', () => { + (processBulkDeleteService.hasSelected as jasmine.Spy).and.returnValue(false); + fixture.detectChanges(); + + const deleteButton = fixture.debugElement.query(By.css('.btn-danger')); + expect(deleteButton).toBeNull(); + }); + }); + + describe('openDeleteModal', () => { + it('should open the modal', () => { + component.openDeleteModal({}); + expect(modalService.open).toHaveBeenCalledWith({}); + }); + }); + + describe('deleteSelected', () => { + it('should call the deleteSelectedProcesses method on the processBulkDeleteService and close the modal when processing is done', () => { + spyOn(component, 'closeModal'); + spyOn(component, 'setProcesses'); + + component.deleteSelected(); + + expect(processBulkDeleteService.deleteSelectedProcesses).toHaveBeenCalled(); + expect(component.closeModal).toHaveBeenCalled(); + expect(component.setProcesses).toHaveBeenCalled(); + }); }); }); diff --git a/src/app/process-page/overview/process-overview.component.ts b/src/app/process-page/overview/process-overview.component.ts index 7afcd9cb76..749f76b1a6 100644 --- a/src/app/process-page/overview/process-overview.component.ts +++ b/src/app/process-page/overview/process-overview.component.ts @@ -1,5 +1,5 @@ -import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable, skipWhile, Subscription } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { Process } from '../processes/process.model'; @@ -11,6 +11,9 @@ import { map, switchMap } from 'rxjs/operators'; import { ProcessDataService } from '../../core/data/processes/process-data.service'; import { PaginationService } from '../../core/pagination/pagination.service'; import { FindListOptions } from '../../core/data/find-list-options.model'; +import { ProcessBulkDeleteService } from './process-bulk-delete.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { hasValue } from '../../shared/empty.util'; @Component({ selector: 'ds-process-overview', @@ -19,7 +22,7 @@ import { FindListOptions } from '../../core/data/find-list-options.model'; /** * Component displaying a list of all processes in a paginated table */ -export class ProcessOverviewComponent implements OnInit { +export class ProcessOverviewComponent implements OnInit, OnDestroy { /** * List of all processes @@ -46,13 +49,22 @@ export class ProcessOverviewComponent implements OnInit { */ dateFormat = 'yyyy-MM-dd HH:mm:ss'; + processesToDelete: string[] = []; + private modalRef: any; + + isProcessingSub: Subscription; + constructor(protected processService: ProcessDataService, protected paginationService: PaginationService, - protected ePersonService: EPersonDataService) { + protected ePersonService: EPersonDataService, + protected modalService: NgbModal, + public processBulkDeleteService: ProcessBulkDeleteService, + ) { } ngOnInit(): void { this.setProcesses(); + this.processBulkDeleteService.clearAllProcesses(); } /** @@ -60,7 +72,7 @@ export class ProcessOverviewComponent implements OnInit { */ setProcesses() { this.processesRD$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe( - switchMap((config) => this.processService.findAll(config)) + switchMap((config) => this.processService.findAll(config, true, false)) ); } @@ -74,8 +86,46 @@ export class ProcessOverviewComponent implements OnInit { map((eperson: EPerson) => eperson.name) ); } + ngOnDestroy(): void { this.paginationService.clearPagination(this.pageConfig.id); + if (hasValue(this.isProcessingSub)) { + this.isProcessingSub.unsubscribe(); + } } + /** + * Open a given modal. + * @param content - the modal content. + */ + openDeleteModal(content) { + this.modalRef = this.modalService.open(content); + } + + /** + * Close the modal. + */ + closeModal() { + this.modalRef.close(); + } + + /** + * Delete the previously selected processes using the processBulkDeleteService + * After the deletion has started, subscribe to the isProcessing$ and when it is set + * to false after the processing is done, close the modal and reinitialise the processes + */ + deleteSelected() { + this.processBulkDeleteService.deleteSelectedProcesses(); + + if (hasValue(this.isProcessingSub)) { + this.isProcessingSub.unsubscribe(); + } + this.isProcessingSub = this.processBulkDeleteService.isProcessing$() + .subscribe((isProcessing) => { + if (!isProcessing) { + this.closeModal(); + this.setProcesses(); + } + }); + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 57863b3a69..31a32e11b7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2983,6 +2983,22 @@ "process.detail.create" : "Create similar process", + "process.detail.actions": "Actions", + + "process.detail.delete.button": "Delete process", + + "process.detail.delete.header": "Delete process", + + "process.detail.delete.body": "Are you sure you want to delete the current process?", + + "process.detail.delete.cancel": "Cancel", + + "process.detail.delete.confirm": "Delete process", + + "process.detail.delete.success": "The process was successfully deleted.", + + "process.detail.delete.error": "Something went wrong when deleting the process", + "process.overview.table.finish" : "Finish time (UTC)", @@ -3003,6 +3019,25 @@ "process.overview.new": "New", + "process.overview.table.actions": "Actions", + + "process.overview.delete": "Delete {{count}} processes", + + "process.overview.delete.clear": "Clear delete selection", + + "process.overview.delete.processing": "{{count}} process(es) are being deleted. Please wait for the deletion to fully complete. Note that this can take a while.", + + "process.overview.delete.body": "Are you sure you want to delete {{count}} process(es)?", + + "process.overview.delete.header": "Delete processes", + + "process.bulk.delete.error.head": "Error on deleteing process", + + "process.bulk.delete.error.body": "The process with ID {{processId}} could not be deleted. The remaining processes will continue being deleted. ", + + "process.bulk.delete.success": "{{count}} process(es) have been succesfully deleted", + + "profile.breadcrumbs": "Update Profile", From ca341e53b44210b3b189bb91743dc8dfd3e88968 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Thu, 25 Aug 2022 15:33:23 +0200 Subject: [PATCH 2/4] Close modal on process delete success --- src/app/process-page/detail/process-detail.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index f9151d27b8..9d92dece61 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -196,6 +196,7 @@ export class ProcessDetailComponent implements OnInit { ).subscribe((rd) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get('process.detail.delete.success')); + this.closeModal(); this.router.navigateByUrl(getProcessListRoute()); } else { this.notificationsService.error(this.translateService.get('process.detail.delete.error')); From 72852dd031060887a7112d769f51be449157ce24 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Thu, 25 Aug 2022 17:05:34 +0200 Subject: [PATCH 3/4] Fix test --- src/app/process-page/detail/process-detail.component.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index 53e65e62eb..e4ab7d1082 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/src/app/process-page/detail/process-detail.component.spec.ts @@ -243,18 +243,23 @@ describe('ProcessDetailComponent', () => { describe('deleteProcess', () => { it('should delete the process and navigate back to the overview page on success', () => { + spyOn(component, 'closeModal'); component.deleteProcess(process); expect(processService.delete).toHaveBeenCalledWith(process.processId); expect(notificationsService.success).toHaveBeenCalled(); + expect(component.closeModal).toHaveBeenCalled(); expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessListRoute()); }); it('should delete the process and not navigate on error', () => { (processService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + spyOn(component, 'closeModal'); + component.deleteProcess(process); expect(processService.delete).toHaveBeenCalledWith(process.processId); expect(notificationsService.error).toHaveBeenCalled(); + expect(component.closeModal).not.toHaveBeenCalled(); expect(router.navigateByUrl).not.toHaveBeenCalled(); }); }); From 154d66f1e8de1c09996f4775495d2aadacb354a0 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Fri, 26 Aug 2022 11:25:07 +0200 Subject: [PATCH 4/4] remove unused imports --- src/app/process-page/overview/process-overview.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/process-page/overview/process-overview.component.ts b/src/app/process-page/overview/process-overview.component.ts index 749f76b1a6..1ca29693cb 100644 --- a/src/app/process-page/overview/process-overview.component.ts +++ b/src/app/process-page/overview/process-overview.component.ts @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, skipWhile, Subscription } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { Process } from '../processes/process.model';