From fffc43f443ca3bd72ee00cca1aeeb96887235b9d Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Tue, 7 Dec 2021 10:14:22 +0100 Subject: [PATCH] 85451: Add a button to export search results as CSV --- .../search-export-csv.component.html | 7 + .../search-export-csv.component.scss | 4 + .../search-export-csv.component.spec.ts | 179 ++++++++++++++++++ .../search-export-csv.component.ts | 102 ++++++++++ .../search-results.component.html | 3 + src/app/shared/shared.module.ts | 2 + src/assets/i18n/en.json5 | 5 + 7 files changed, 302 insertions(+) create mode 100644 src/app/shared/search/search-export-csv/search-export-csv.component.html create mode 100644 src/app/shared/search/search-export-csv/search-export-csv.component.scss create mode 100644 src/app/shared/search/search-export-csv/search-export-csv.component.spec.ts create mode 100644 src/app/shared/search/search-export-csv/search-export-csv.component.ts diff --git a/src/app/shared/search/search-export-csv/search-export-csv.component.html b/src/app/shared/search/search-export-csv/search-export-csv.component.html new file mode 100644 index 0000000000..7bf8704300 --- /dev/null +++ b/src/app/shared/search/search-export-csv/search-export-csv.component.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/src/app/shared/search/search-export-csv/search-export-csv.component.scss b/src/app/shared/search/search-export-csv/search-export-csv.component.scss new file mode 100644 index 0000000000..4b0ab3c44a --- /dev/null +++ b/src/app/shared/search/search-export-csv/search-export-csv.component.scss @@ -0,0 +1,4 @@ +.export-button { + background: var(--ds-admin-sidebar-bg); + border-color: var(--ds-admin-sidebar-bg); +} \ No newline at end of file diff --git a/src/app/shared/search/search-export-csv/search-export-csv.component.spec.ts b/src/app/shared/search/search-export-csv/search-export-csv.component.spec.ts new file mode 100644 index 0000000000..f8dc089c6a --- /dev/null +++ b/src/app/shared/search/search-export-csv/search-export-csv.component.spec.ts @@ -0,0 +1,179 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { SearchExportCsvComponent } from './search-export-csv.component'; +import { ScriptDataService } from '../../../core/data/processes/script-data.service'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { Script } from '../../../process-page/scripts/script.model'; +import { Process } from '../../../process-page/processes/process.model'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { PaginatedSearchOptions } from '../paginated-search-options.model'; +import { SearchFilter } from '../search-filter.model'; +import { getProcessDetailRoute } from '../../../process-page/process-page-routing.paths'; + +describe('SearchExportCsvComponent', () => { + let component: SearchExportCsvComponent; + let fixture: ComponentFixture; + + let scriptDataService: ScriptDataService; + let authorizationDataService: AuthorizationDataService; + let notificationsService; + let router; + + const script = Object.assign(new Script(), {id: 'metadata-export-search', name: 'metadata-export-search'}); + const process = Object.assign(new Process(), {processId: 5, scriptName: 'metadata-export-search'}); + + const searchConfig = new PaginatedSearchOptions({ + configuration: 'test-configuration', + scope: 'test-scope', + query: 'test-query', + filters: [ + new SearchFilter('f.filter1', ['filter1value1,equals', 'filter1value2,equals']), + new SearchFilter('f.filter2', ['filter2value1,contains']) + ] + }); + + function initBeforeEachAsync() { + scriptDataService = jasmine.createSpyObj('scriptDataService', { + findById: createSuccessfulRemoteDataObject$(script), + invoke: createSuccessfulRemoteDataObject$(process) + }); + authorizationDataService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + + notificationsService = new NotificationsServiceStub(); + + router = jasmine.createSpyObj('authorizationService', ['navigateByUrl']); + TestBed.configureTestingModule({ + declarations: [SearchExportCsvComponent], + imports: [TranslateModule.forRoot(), NgbModule], + providers: [ + {provide: ScriptDataService, useValue: scriptDataService}, + {provide: AuthorizationDataService, useValue: authorizationDataService}, + {provide: NotificationsService, useValue: notificationsService}, + {provide: Router, useValue: router}, + ] + }).compileComponents(); + } + + function initBeforeEach() { + fixture = TestBed.createComponent(SearchExportCsvComponent); + component = fixture.componentInstance; + component.searchConfig = searchConfig; + fixture.detectChanges(); + } + + describe('init', () => { + describe('comp', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should init the comp', () => { + expect(component).toBeTruthy(); + }); + }); + describe('when the user is an admin and the metadata-export-search script is present ', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeDefined(); + }); + }); + describe('when the user is not an admin', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + (authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should not add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeNull(); + }); + }); + describe('when the metadata-export-search script is not present', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + (scriptDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not found', 404)); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should should not add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeNull(); + }); + }); + }); + describe('export', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should call the invoke script method with the correct parameters', () => { + component.export(); + expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-search', + [ + {name: '-q', value: searchConfig.query}, + {name: '-s', value: searchConfig.scope}, + {name: '-c', value: searchConfig.configuration}, + {name: '-f', value: 'filter1,equals=filter1value1,filter1value2'}, + {name: '-f', value: 'filter2,contains=filter2value1'}, + ], []); + + component.searchConfig = null; + fixture.detectChanges(); + + component.export(); + expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-search', [], []); + + }); + it('should show a success message when the script was invoked successfully and redirect to the corresponding process page', () => { + component.export(); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessDetailRoute(process.processId)); + }); + it('should show an error message when the script was not invoked successfully and stay on the current page', () => { + (scriptDataService.invoke as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 500)); + + component.export(); + + expect(notificationsService.error).toHaveBeenCalled(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + describe('clicking the button', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should trigger the export function', () => { + spyOn(component, 'export'); + + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + debugElement.triggerEventHandler('click', null); + + expect(component.export).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/shared/search/search-export-csv/search-export-csv.component.ts b/src/app/shared/search/search-export-csv/search-export-csv.component.ts new file mode 100644 index 0000000000..a1bdf1c7e1 --- /dev/null +++ b/src/app/shared/search/search-export-csv/search-export-csv.component.ts @@ -0,0 +1,102 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { PaginatedSearchOptions } from '../paginated-search-options.model'; +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { ScriptDataService } from '../../../core/data/processes/script-data.service'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { map, tap } from 'rxjs/operators'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { hasValue, isNotEmpty } from '../../empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Process } from '../../../process-page/processes/process.model'; +import { getProcessDetailRoute } from '../../../process-page/process-page-routing.paths'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'ds-search-export-csv', + styleUrls: ['./search-export-csv.component.scss'], + templateUrl: './search-export-csv.component.html', +}) +/** + * Display a button to export the current search results as csv + */ +export class SearchExportCsvComponent implements OnInit { + + /** + * The current configuration of the search + */ + @Input() searchConfig: PaginatedSearchOptions; + + /** + * Observable used to determine whether the button should be shown + */ + shouldShowButton$: Observable; + + /** + * The message key used for the tooltip of the button + */ + tooltipMsg = 'metadata-export-search.tooltip'; + + constructor(private scriptDataService: ScriptDataService, + private authorizationDataService: AuthorizationDataService, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private router: Router + ) { + } + + ngOnInit(): void { + const scriptExists$ = this.scriptDataService.findById('metadata-export-search').pipe( + getFirstCompletedRemoteData(), + map((rd) => rd.isSuccess && hasValue(rd.payload)) + ); + + const isAuthorized$ = this.authorizationDataService.isAuthorized(FeatureID.AdministratorOf); + + this.shouldShowButton$ = observableCombineLatest([scriptExists$, isAuthorized$]).pipe( + tap((v) => console.log('showbutton', v)), + map(([scriptExists, isAuthorized]: [boolean, boolean]) => scriptExists && isAuthorized) + ); + } + + /** + * Start the export of the items based on the current search configuration + */ + export() { + const parameters = []; + if (hasValue(this.searchConfig)) { + if (isNotEmpty(this.searchConfig.query)) { + parameters.push({name: '-q', value: this.searchConfig.query}); + } + if (isNotEmpty(this.searchConfig.scope)) { + parameters.push({name: '-s', value: this.searchConfig.scope}); + } + if (isNotEmpty(this.searchConfig.configuration)) { + parameters.push({name: '-c', value: this.searchConfig.configuration}); + } + if (isNotEmpty(this.searchConfig.filters)) { + this.searchConfig.filters.forEach((filter) => { + let operator = 'equals'; + if (hasValue(filter.values)) { + operator = filter.values[0].substring(filter.values[0].indexOf(',') + 1); + } + const filterValue = `${filter.key.substring(2)},${operator}=${filter.values.map((v) => v.substring(0, v.indexOf(','))).join()}`; + parameters.push({name: '-f', value: filterValue}); + }); + } + } + + this.scriptDataService.invoke('metadata-export-search', parameters, []).pipe( + getFirstCompletedRemoteData() + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('metadata-export-search.submit.success')); + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } else { + this.notificationsService.error(this.translateService.get('metadata-export-search.submit.error')); + } + }); + } +} diff --git a/src/app/shared/search/search-results/search-results.component.html b/src/app/shared/search/search-results/search-results.component.html index 4e6bca094e..01c63e9c9e 100644 --- a/src/app/shared/search/search-results/search-results.component.html +++ b/src/app/shared/search/search-results/search-results.component.html @@ -1,4 +1,7 @@ +

{{ (configuration ? configuration + '.search.results.head' : 'search.results.head') | translate }}

+ +