mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
85451: Add a button to export search results as CSV
This commit is contained in:
@@ -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>
|
@@ -0,0 +1,4 @@
|
||||
.export-button {
|
||||
background: var(--ds-admin-sidebar-bg);
|
||||
border-color: var(--ds-admin-sidebar-bg);
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -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"
|
||||
|
@@ -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,
|
||||
|
@@ -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": "",
|
||||
|
Reference in New Issue
Block a user