mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +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>
|
<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>
|
<div *ngIf="searchResults?.hasSucceeded && !searchResults?.isLoading && searchResults?.payload?.page.length > 0" @fadeIn>
|
||||||
<ds-viewable-collection
|
<ds-viewable-collection
|
||||||
[config]="searchConfig.pagination"
|
[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 { 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 { 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 { 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
|
* Declaration needed to make sure all decorator functions are called in time
|
||||||
@@ -387,6 +388,7 @@ const COMPONENTS = [
|
|||||||
SearchSettingsComponent,
|
SearchSettingsComponent,
|
||||||
CollectionSearchResultGridElementComponent,
|
CollectionSearchResultGridElementComponent,
|
||||||
CommunitySearchResultGridElementComponent,
|
CommunitySearchResultGridElementComponent,
|
||||||
|
SearchExportCsvComponent,
|
||||||
SearchFiltersComponent,
|
SearchFiltersComponent,
|
||||||
SearchFilterComponent,
|
SearchFilterComponent,
|
||||||
SearchFacetFilterComponent,
|
SearchFacetFilterComponent,
|
||||||
|
@@ -2528,6 +2528,11 @@
|
|||||||
"menu.section.workflow": "Administer Workflow",
|
"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.breadcrumbs": "MyDSpace",
|
||||||
|
|
||||||
"mydspace.description": "",
|
"mydspace.description": "",
|
||||||
|
Reference in New Issue
Block a user