Merge pull request #1792 from atmire/w2p-93914_delete-process

Add delete process functionality
This commit is contained in:
Tim Donohue
2022-09-07 11:29:55 -05:00
committed by GitHub
9 changed files with 691 additions and 42 deletions

View File

@@ -1,20 +1,22 @@
<div class="container" *ngVar="(processRD$ | async)?.payload as process">
<div class="d-flex">
<h2 class="flex-grow-1">{{'process.detail.title' | translate:{id: process?.processId, name: process?.scriptName} }}</h2>
<div>
<button class="btn btn-lg btn-success " routerLink="/processes/new" [queryParams]="{id: process?.processId}"><i class="fas fa-plus pr-2"></i>{{'process.detail.create' | translate}}</button>
</div>
<h2 class="flex-grow-1">{{'process.detail.title' | translate:{
id: process?.processId,
name: process?.scriptName
} }}</h2>
</div>
<ds-process-detail-field id="process-name" [title]="'process.detail.script'">
<div>{{ process?.scriptName }}</div>
</ds-process-detail-field>
<ds-process-detail-field *ngIf="process?.parameters && process?.parameters?.length > 0" id="process-arguments" [title]="'process.detail.arguments'">
<ds-process-detail-field *ngIf="process?.parameters && process?.parameters?.length > 0" id="process-arguments"
[title]="'process.detail.arguments'">
<div *ngFor="let argument of process?.parameters">{{ argument?.name }} {{ argument?.value }}</div>
</ds-process-detail-field>
<div *ngVar="(filesRD$ | async)?.payload?.page as files">
<ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files" [title]="'process.detail.output-files'">
<ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files"
[title]="'process.detail.output-files'">
<ds-file-download-link *ngFor="let file of files; let last=last;" [bitstream]="file">
<span>{{getFileName(file)}}</span>
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
@@ -22,23 +24,28 @@
</ds-process-detail-field>
</div>
<ds-process-detail-field *ngIf="process && process.startTime" id="process-start-time" [title]="'process.detail.start-time' | translate">
<ds-process-detail-field *ngIf="process && process.startTime" id="process-start-time"
[title]="'process.detail.start-time' | translate">
<div>{{ process.startTime | date:dateFormat:'UTC' }}</div>
</ds-process-detail-field>
<ds-process-detail-field *ngIf="process && process.endTime" id="process-end-time" [title]="'process.detail.end-time' | translate">
<ds-process-detail-field *ngIf="process && process.endTime" id="process-end-time"
[title]="'process.detail.end-time' | translate">
<div>{{ process.endTime | date:dateFormat:'UTC' }}</div>
</ds-process-detail-field>
<ds-process-detail-field *ngIf="process && process.processStatus" id="process-status" [title]="'process.detail.status' | translate">
<ds-process-detail-field *ngIf="process && process.processStatus" id="process-status"
[title]="'process.detail.status' | translate">
<div>{{ process.processStatus }}</div>
</ds-process-detail-field>
<ds-process-detail-field *ngIf="isProcessFinished(process)" id="process-output" [title]="'process.detail.output'">
<button *ngIf="!showOutputLogs && process?._links?.output?.href != undefined" id="showOutputButton" class="btn btn-primary" (click)="showProcessOutputLogs()">
<button *ngIf="!showOutputLogs && process?._links?.output?.href != undefined" id="showOutputButton"
class="btn btn-primary" (click)="showProcessOutputLogs()">
{{ 'process.detail.logs.button' | translate }}
</button>
<ds-themed-loading *ngIf="retrievingOutputLogs$ | async" class="ds-themed-loading" message="{{ 'process.detail.logs.loading' | translate }}"></ds-themed-loading>
<ds-themed-loading *ngIf="retrievingOutputLogs$ | async" class="ds-themed-loading"
message="{{ 'process.detail.logs.loading' | translate }}"></ds-themed-loading>
<pre class="font-weight-bold text-secondary bg-light p-3"
*ngIf="showOutputLogs && (outputLogs$ | async)?.length > 0">{{ (outputLogs$ | async) }}</pre>
<p id="no-output-logs-message" *ngIf="(!(retrievingOutputLogs$ | async) && showOutputLogs)
@@ -47,7 +54,46 @@
</p>
</ds-process-detail-field>
<ds-process-detail-field id="process-actions" [title]="'process.detail.actions'">
<button class="btn btn-success mr-2" routerLink="/processes/new" [queryParams]="{id: process?.processId}"><i
class="fas fa-plus pr-2"></i>{{'process.detail.create' | translate}}</button>
<button *ngIf="isProcessFinished(process)" id="delete" class="btn btn-danger"
(click)="openDeleteModal(deleteModal)">
<i class="fas fa-trash pr-2"></i>{{ 'process.detail.delete.button' | translate }}
</button>
</ds-process-detail-field>
<div style="text-align: right;">
<a class="btn btn-outline-secondary mt-3" [routerLink]="'/processes'">{{'process.detail.back' | translate}}</a>
</div>
</div>
<ng-template #deleteModal >
<div *ngVar="(processRD$ | async)?.payload as process">
<div class="modal-header">
<div>
<h4>{{'process.detail.delete.header' | translate }}</h4>
</div>
<button type="button" class="close"
(click)="closeModal()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div>{{'process.detail.delete.body' | translate }}</div>
<div class="mt-4">
<button class="btn btn-primary mr-2" (click)="closeModal()">{{'process.detail.delete.cancel' | translate}}</button>
<button id="delete-confirm" class="btn btn-danger"
(click)="deleteProcess(process)">{{ 'process.detail.delete.confirm' | translate }}
</button>
</div>
</div>
</div>
</ng-template>

View File

@@ -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,34 @@ 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', () => {
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();
});
});
});

View File

@@ -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,36 @@ 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.closeModal();
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();
}
}

View File

@@ -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']);
});
});
});

View File

@@ -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<boolean> = new BehaviorSubject<boolean>(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<Process>) => {
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<Process>) => rd.hasSucceeded),
count(),
).subscribe((value) => {
this.notificationsService.success(this.translateService.get('process.bulk.delete.success', {count: value}));
this.isProcessingBehaviorSubject.next(false);
});
}
}

View File

@@ -1,7 +1,19 @@
<div class="container">
<div class="d-flex">
<h2 class="flex-grow-1">{{'process.overview.title' | translate}}</h2>
<button class="btn btn-lg btn-success " routerLink="/processes/new"><i class="fas fa-plus pr-2"></i>{{'process.overview.new' | translate}}</button>
</div>
<div class="d-flex justify-content-end">
<button *ngIf="processBulkDeleteService.hasSelected()" class="btn btn-primary mr-2"
(click)="processBulkDeleteService.clearAllProcesses()"><i
class="fas fa-undo pr-2"></i>{{'process.overview.delete.clear' | translate }}
</button>
<button *ngIf="processBulkDeleteService.hasSelected()" class="btn btn-danger mr-2"
(click)="openDeleteModal(deleteModal)"><i
class="fas fa-trash pr-2"></i>{{'process.overview.delete' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}
</button>
<button class="btn btn-success" routerLink="/processes/new"><i
class="fas fa-plus pr-2"></i>{{'process.overview.new' | translate}}</button>
</div>
<ds-pagination *ngIf="(processesRD$ | async)?.payload?.totalElements > 0"
[paginationOptions]="pageConfig"
@@ -19,19 +31,61 @@
<th scope="col">{{'process.overview.table.start' | translate}}</th>
<th scope="col">{{'process.overview.table.finish' | translate}}</th>
<th scope="col">{{'process.overview.table.status' | translate}}</th>
<th scope="col">{{'process.overview.table.actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let process of (processesRD$ | async)?.payload?.page">
<tr *ngFor="let process of (processesRD$ | async)?.payload?.page"
[class.table-danger]="processBulkDeleteService.isToBeDeleted(process.processId)">
<td><a [routerLink]="['/processes/', process.processId]">{{process.processId}}</a></td>
<td><a [routerLink]="['/processes/', process.processId]">{{process.scriptName}}</a></td>
<td *ngVar="(getEpersonName(process.userId) | async) as ePersonName">{{ePersonName}}</td>
<td>{{process.startTime | date:dateFormat:'UTC'}}</td>
<td>{{process.endTime | date:dateFormat:'UTC'}}</td>
<td>{{process.processStatus}}</td>
<td>
<button class="btn btn-outline-danger"
(click)="processBulkDeleteService.toggleDelete(process.processId)"><i
class="fas fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
</div>
<ng-template #deleteModal>
<div>
<div class="modal-header">
<div>
<h4>{{'process.overview.delete.header' | translate }}</h4>
</div>
<button type="button" class="close"
(click)="closeModal()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div *ngIf="!(processBulkDeleteService.isProcessing$() |async)">{{'process.overview.delete.body' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}</div>
<div *ngIf="processBulkDeleteService.isProcessing$() |async" class="alert alert-info">
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
<span> {{ 'process.overview.delete.processing' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}</span>
</div>
<div class="mt-4">
<button class="btn btn-primary mr-2" [disabled]="processBulkDeleteService.isProcessing$() |async"
(click)="closeModal()">{{'process.detail.delete.cancel' | translate}}</button>
<button id="delete-confirm" class="btn btn-danger"
[disabled]="processBulkDeleteService.isProcessing$() |async"
(click)="deleteSelected()">{{ 'process.overview.delete' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}
</button>
</div>
</div>
</div>
</ng-template>

View File

@@ -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();
});
});
});

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Component, OnDestroy, OnInit } from '@angular/core';
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';
@@ -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();
}
});
}
}

View File

@@ -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",