85451: Add a button to export search results as CSV

This commit is contained in:
Yana De Pauw
2021-12-07 10:14:22 +01:00
parent 46d340a5ce
commit fffc43f443
7 changed files with 302 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<button *ngIf="shouldShowButton$ | async"
class="export-button btn btn-dark btn-sm"
[ngbTooltip]="tooltipMsg | translate"
(click)="export()"
[title]="tooltipMsg |translate" [attr.aria-label]="tooltipMsg |translate">
<i class="fas fa-file-export fa-fw"></i>
</button>

View File

@@ -0,0 +1,4 @@
.export-button {
background: var(--ds-admin-sidebar-bg);
border-color: var(--ds-admin-sidebar-bg);
}

View File

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

View File

@@ -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<boolean>;
/**
* 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<Process>) => {
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'));
}
});
}
}

View File

@@ -1,4 +1,7 @@
<div class="d-flex justify-content-between">
<h2 *ngIf="!disableHeader">{{ (configuration ? configuration + '.search.results.head' : 'search.results.head') | translate }}</h2>
<ds-search-export-csv [searchConfig]="searchConfig"></ds-search-export-csv>
</div>
<div *ngIf="searchResults?.hasSucceeded && !searchResults?.isLoading && searchResults?.payload?.page.length > 0" @fadeIn>
<ds-viewable-collection
[config]="searchConfig.pagination"

View File

@@ -241,6 +241,7 @@ import { ItemVersionsSummaryModalComponent } from './item/item-versions/item-ver
import { ItemVersionsDeleteModalComponent } from './item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component';
import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component';
import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component';
import { SearchExportCsvComponent } from './search/search-export-csv/search-export-csv.component';
/**
* Declaration needed to make sure all decorator functions are called in time
@@ -387,6 +388,7 @@ const COMPONENTS = [
SearchSettingsComponent,
CollectionSearchResultGridElementComponent,
CommunitySearchResultGridElementComponent,
SearchExportCsvComponent,
SearchFiltersComponent,
SearchFilterComponent,
SearchFacetFilterComponent,

View File

@@ -2528,6 +2528,11 @@
"menu.section.workflow": "Administer Workflow",
"metadata-export-search.tooltip": "Export search results as CSV",
"metadata-export-search.submit.success": "The export was started successfully",
"metadata-export-search.submit.error": "Starting the export has failed",
"mydspace.breadcrumbs": "MyDSpace",
"mydspace.description": "",