Skip to content

Commit 5b7d246

Browse files
jeffmorinJean-François Morin
andauthored
CSV export for Filtered Items content report (#4071)
* CSV export for Filtered Items content report * Fixed lint errors * Fixed lint errors * Fixed lint errors * Make variables for CSV export null-proof * Attempt to fix unit tests * Fixed styling errors * Fixed script references in unit tests * Fixed typo in script name * Fixed test parameterization * Parameterization attempt * Parameterization test * Parameterization rollback * Fixed predicate encoding bug * Parameterization test * Fixed styling error * Fixed query predicate parameter * Fixed collection parameterization * Centralized string representation of a predicate * Fixed parameterization * Fixed second export test * Replaced null payload by an empty non-null one * Requested changes * Fixed remaining bugs * Updated Angular control flow syntax * Improved collection parameter handling * Fixed styling error * Updated config.yml to match the central dspace-angular repo * Removed repeated content * Cleaned up a now useless import * Fixed collections loading and added warning message about CSV export * Fixed styling error * Forgot to clean up old code --------- Co-authored-by: Jean-François Morin <jean-francois.morin@bibl.ulaval.ca>
1 parent fd59ca8 commit 5b7d246

11 files changed

+415
-17
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@if (shouldShowButton$ | async) {
2+
<button class="export-button btn btn-dark btn-sm"
3+
[ngbTooltip]="tooltipMsg | translate"
4+
(click)="export()"
5+
[title]="tooltipMsg | translate" [attr.aria-label]="tooltipMsg | translate">
6+
<i class="fas fa-file-export fa-fw"></i>
7+
</button>
8+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.export-button {
2+
background: var(--ds-admin-sidebar-bg);
3+
border-color: var(--ds-admin-sidebar-bg);
4+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
ComponentFixture,
3+
TestBed,
4+
waitForAsync,
5+
} from '@angular/core/testing';
6+
import {
7+
FormControl,
8+
FormGroup,
9+
} from '@angular/forms';
10+
import { By } from '@angular/platform-browser';
11+
import { Router } from '@angular/router';
12+
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
13+
import { TranslateModule } from '@ngx-translate/core';
14+
import { of as observableOf } from 'rxjs';
15+
16+
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
17+
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
18+
import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
19+
import { Process } from '../../../../process-page/processes/process.model';
20+
import { Script } from '../../../../process-page/scripts/script.model';
21+
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
22+
import {
23+
createFailedRemoteDataObject$,
24+
createSuccessfulRemoteDataObject$,
25+
} from '../../../../shared/remote-data.utils';
26+
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
27+
import { FiltersComponent } from '../../filters-section/filters-section.component';
28+
import { OptionVO } from '../option-vo.model';
29+
import { QueryPredicate } from '../query-predicate.model';
30+
import { FilteredItemsExportCsvComponent } from './filtered-items-export-csv.component';
31+
32+
describe('FilteredItemsExportCsvComponent', () => {
33+
let component: FilteredItemsExportCsvComponent;
34+
let fixture: ComponentFixture<FilteredItemsExportCsvComponent>;
35+
36+
let scriptDataService: ScriptDataService;
37+
let authorizationDataService: AuthorizationDataService;
38+
let notificationsService;
39+
let router;
40+
41+
const script = Object.assign(new Script(), { id: 'metadata-export-filtered-items-report', name: 'metadata-export-filtered-items-report' });
42+
const process = Object.assign(new Process(), { processId: 5, scriptName: 'metadata-export-filtered-items-report' });
43+
44+
const params = new FormGroup({
45+
collections: new FormControl([OptionVO.collection('1', 'coll1')]),
46+
queryPredicates: new FormControl([QueryPredicate.of('name', 'equals', 'coll1')]),
47+
filters: new FormControl([FiltersComponent.getFilter('is_item')]),
48+
});
49+
50+
const emptyParams = new FormGroup({
51+
collections: new FormControl([]),
52+
queryPredicates: new FormControl([]),
53+
filters: new FormControl([]),
54+
});
55+
56+
function initBeforeEachAsync() {
57+
scriptDataService = jasmine.createSpyObj('scriptDataService', {
58+
findById: createSuccessfulRemoteDataObject$(script),
59+
invoke: createSuccessfulRemoteDataObject$(process),
60+
});
61+
authorizationDataService = jasmine.createSpyObj('authorizationService', {
62+
isAuthorized: observableOf(true),
63+
});
64+
65+
notificationsService = new NotificationsServiceStub();
66+
67+
router = jasmine.createSpyObj('authorizationService', ['navigateByUrl']);
68+
TestBed.configureTestingModule({
69+
imports: [TranslateModule.forRoot(), NgbModule, FilteredItemsExportCsvComponent],
70+
providers: [
71+
{ provide: ScriptDataService, useValue: scriptDataService },
72+
{ provide: AuthorizationDataService, useValue: authorizationDataService },
73+
{ provide: NotificationsService, useValue: notificationsService },
74+
{ provide: Router, useValue: router },
75+
],
76+
}).compileComponents();
77+
}
78+
79+
function initBeforeEach() {
80+
fixture = TestBed.createComponent(FilteredItemsExportCsvComponent);
81+
component = fixture.componentInstance;
82+
component.reportParams = params;
83+
fixture.detectChanges();
84+
}
85+
86+
describe('init', () => {
87+
describe('comp', () => {
88+
beforeEach(waitForAsync(() => {
89+
initBeforeEachAsync();
90+
}));
91+
beforeEach(() => {
92+
initBeforeEach();
93+
});
94+
it('should init the comp', () => {
95+
expect(component).toBeTruthy();
96+
});
97+
});
98+
describe('when the user is an admin and the metadata-export-filtered-items-report script is present ', () => {
99+
beforeEach(waitForAsync(() => {
100+
initBeforeEachAsync();
101+
}));
102+
beforeEach(() => {
103+
initBeforeEach();
104+
});
105+
it('should add the button', () => {
106+
const debugElement = fixture.debugElement.query(By.css('button.export-button'));
107+
expect(debugElement).toBeDefined();
108+
});
109+
});
110+
describe('when the user is not an admin', () => {
111+
beforeEach(waitForAsync(() => {
112+
initBeforeEachAsync();
113+
(authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
114+
}));
115+
beforeEach(() => {
116+
initBeforeEach();
117+
});
118+
it('should not add the button', () => {
119+
const debugElement = fixture.debugElement.query(By.css('button.export-button'));
120+
expect(debugElement).toBeNull();
121+
});
122+
});
123+
describe('when the metadata-export-filtered-items-report script is not present', () => {
124+
beforeEach(waitForAsync(() => {
125+
initBeforeEachAsync();
126+
(scriptDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not found', 404));
127+
}));
128+
beforeEach(() => {
129+
initBeforeEach();
130+
});
131+
it('should should not add the button', () => {
132+
const debugElement = fixture.debugElement.query(By.css('button.export-button'));
133+
expect(debugElement).toBeNull();
134+
});
135+
});
136+
});
137+
describe('export', () => {
138+
beforeEach(waitForAsync(() => {
139+
initBeforeEachAsync();
140+
}));
141+
beforeEach(() => {
142+
initBeforeEach();
143+
});
144+
it('should call the invoke script method with the correct parameters', () => {
145+
// Parameterized export
146+
component.export();
147+
expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-filtered-items-report',
148+
[
149+
{ name: '-c', value: params.value.collections[0].id },
150+
{ name: '-qp', value: QueryPredicate.toString(params.value.queryPredicates[0]) },
151+
{ name: '-f', value: FiltersComponent.toQueryString(params.value.filters) },
152+
], []);
153+
154+
fixture.detectChanges();
155+
156+
// Non-parameterized export
157+
component.reportParams = emptyParams;
158+
fixture.detectChanges();
159+
component.export();
160+
expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-filtered-items-report', [], []);
161+
162+
});
163+
it('should show a success message when the script was invoked successfully and redirect to the corresponding process page', () => {
164+
component.export();
165+
166+
expect(notificationsService.success).toHaveBeenCalled();
167+
expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessDetailRoute(process.processId));
168+
});
169+
it('should show an error message when the script was not invoked successfully and stay on the current page', () => {
170+
(scriptDataService.invoke as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 500));
171+
172+
component.export();
173+
174+
expect(notificationsService.error).toHaveBeenCalled();
175+
expect(router.navigateByUrl).not.toHaveBeenCalled();
176+
});
177+
});
178+
describe('clicking the button', () => {
179+
beforeEach(waitForAsync(() => {
180+
initBeforeEachAsync();
181+
}));
182+
beforeEach(() => {
183+
initBeforeEach();
184+
});
185+
it('should trigger the export function', () => {
186+
spyOn(component, 'export');
187+
188+
const debugElement = fixture.debugElement.query(By.css('button.export-button'));
189+
debugElement.triggerEventHandler('click', null);
190+
191+
expect(component.export).toHaveBeenCalled();
192+
});
193+
});
194+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { AsyncPipe } from '@angular/common';
2+
import {
3+
Component,
4+
Input,
5+
OnInit,
6+
} from '@angular/core';
7+
import { FormGroup } from '@angular/forms';
8+
import { Router } from '@angular/router';
9+
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
10+
import {
11+
TranslateModule,
12+
TranslateService,
13+
} from '@ngx-translate/core';
14+
import {
15+
combineLatest as observableCombineLatest,
16+
Observable,
17+
} from 'rxjs';
18+
import { map } from 'rxjs/operators';
19+
20+
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
21+
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
22+
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
23+
import { RemoteData } from '../../../../core/data/remote-data';
24+
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
25+
import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
26+
import { Process } from '../../../../process-page/processes/process.model';
27+
import { hasValue } from '../../../../shared/empty.util';
28+
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
29+
import { FiltersComponent } from '../../filters-section/filters-section.component';
30+
import { OptionVO } from '../option-vo.model';
31+
import { QueryPredicate } from '../query-predicate.model';
32+
33+
@Component({
34+
selector: 'ds-filtered-items-export-csv',
35+
styleUrls: ['./filtered-items-export-csv.component.scss'],
36+
templateUrl: './filtered-items-export-csv.component.html',
37+
standalone: true,
38+
imports: [NgbTooltipModule, AsyncPipe, TranslateModule],
39+
})
40+
/**
41+
* Display a button to export the MetadataQuery (aka Filtered Items) Report results as csv
42+
*/
43+
export class FilteredItemsExportCsvComponent implements OnInit {
44+
45+
/**
46+
* The current configuration of the search
47+
*/
48+
@Input() reportParams: FormGroup;
49+
50+
/**
51+
* Observable used to determine whether the button should be shown
52+
*/
53+
shouldShowButton$: Observable<boolean>;
54+
55+
/**
56+
* The message key used for the tooltip of the button
57+
*/
58+
tooltipMsg = 'metadata-export-filtered-items.tooltip';
59+
60+
constructor(private scriptDataService: ScriptDataService,
61+
private authorizationDataService: AuthorizationDataService,
62+
private notificationsService: NotificationsService,
63+
private translateService: TranslateService,
64+
private router: Router,
65+
) {
66+
}
67+
68+
static csvExportEnabled(scriptDataService: ScriptDataService, authorizationDataService: AuthorizationDataService): Observable<boolean> {
69+
const scriptExists$ = scriptDataService.findById('metadata-export-filtered-items-report').pipe(
70+
getFirstCompletedRemoteData(),
71+
map((rd) => rd.isSuccess && hasValue(rd.payload)),
72+
);
73+
74+
const isAuthorized$ = authorizationDataService.isAuthorized(FeatureID.AdministratorOf);
75+
76+
return observableCombineLatest([scriptExists$, isAuthorized$]).pipe(
77+
map(([scriptExists, isAuthorized]: [boolean, boolean]) => scriptExists && isAuthorized),
78+
);
79+
}
80+
81+
ngOnInit(): void {
82+
this.shouldShowButton$ = FilteredItemsExportCsvComponent.csvExportEnabled(this.scriptDataService, this.authorizationDataService);
83+
}
84+
85+
/**
86+
* Start the export of the items based on the selected parameters
87+
*/
88+
export() {
89+
const parameters = [];
90+
const colls = this.reportParams.value.collections || [];
91+
for (let i = 0; i < colls.length; i++) {
92+
if (colls[i]) {
93+
parameters.push({ name: '-c', value: OptionVO.toString(colls[i]) });
94+
}
95+
}
96+
97+
const preds = this.reportParams.value.queryPredicates || [];
98+
for (let i = 0; i < preds.length; i++) {
99+
const field = preds[i].field;
100+
const op = preds[i].operator;
101+
if (field && op) {
102+
parameters.push({ name: '-qp', value: QueryPredicate.toString(preds[i]) });
103+
}
104+
}
105+
106+
const filters = FiltersComponent.toQueryString(this.reportParams.value.filters) || [];
107+
if (filters.length > 0) {
108+
parameters.push({ name: '-f', value: filters });
109+
}
110+
111+
this.scriptDataService.invoke('metadata-export-filtered-items-report', parameters, []).pipe(
112+
getFirstCompletedRemoteData(),
113+
).subscribe((rd: RemoteData<Process>) => {
114+
if (rd.hasSucceeded) {
115+
this.notificationsService.success(this.translateService.get('metadata-export-filtered-items.submit.success'));
116+
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
117+
} else {
118+
this.notificationsService.error(this.translateService.get('metadata-export-filtered-items.submit.error'));
119+
}
120+
});
121+
}
122+
123+
}

src/app/admin/admin-reports/filtered-items/filtered-items.component.html

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@ <h1 id="header" class="border-bottom pb-2">{{'admin.reports.items.head' | transl
1111
{{'admin.reports.items.section.collectionSelector' | translate}}
1212
</ng-template>
1313
<ng-template ngbPanelContent>
14-
<select id="collSel" name="collSel" class="form-control" multiple="multiple" size="10" formControlName="collections">
15-
@for (item of collections; track item) {
16-
<option [value]="item.id" [disabled]="item.disabled">{{item.name$ | async}}</option>
17-
}
18-
</select>
14+
@if (loadingCollections$ | async) {
15+
<ds-loading></ds-loading>
16+
}
17+
@if ((loadingCollections$ | async) !== true) {
18+
<select id="collSel" name="collSel" class="form-control" multiple="multiple" size="10" formControlName="collections">
19+
@for (item of collections; track item) {
20+
<option [value]="item.id" [disabled]="item.disabled">{{item.name$ | async}}</option>
21+
}
22+
</select>
23+
}
1924
<div class="row">
2025
<span class="col-3"></span>
2126
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
@@ -132,6 +137,10 @@ <h1 id="header" class="border-bottom pb-2">{{'admin.reports.items.head' | transl
132137
</select>
133138
</div>
134139
<div class="row">
140+
@if (csvExportEnabled$ | async) {
141+
<span class="col-3"></span>
142+
<div class="warning">{{ 'metadata-export-filtered-items.columns.warning' | translate }}</div>
143+
}
135144
<span class="col-3"></span>
136145
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
137146
</div>
@@ -186,9 +195,9 @@ <h1 id="header" class="border-bottom pb-2">{{'admin.reports.items.head' | transl
186195
<div>
187196
<button id="prev" class="btn btn-light" (click)="prevPage()" [dsBtnDisabled]="!canNavigatePrevious()">{{'admin.reports.commons.previous-page' | translate}}</button>
188197
<button id="next" class="btn btn-light" (click)="nextPage()" [dsBtnDisabled]="!canNavigateNext()">{{'admin.reports.commons.next-page' | translate}}</button>
189-
<!--
190-
<button id="export">{{'admin.reports.commons.export' | translate}}</button>
191-
-->
198+
<div style="float: right; margin-right: 60px;">
199+
<ds-filtered-items-export-csv [reportParams]="queryForm"></ds-filtered-items-export-csv>
200+
</div>
192201
</div>
193202
<table id="itemtable" class="sortable"></table>
194203
</ng-template>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
.num {
22
text-align: center;
33
}
4+
5+
.warning {
6+
color: red;
7+
font-style: italic;
8+
text-align: center;
9+
width: 100%;
10+
}

0 commit comments

Comments
 (0)