Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2545890
CSV export for Filtered Items content report
Feb 13, 2025
0e07681
Merge branch 'DSpace:main' into main
jeffmorin Feb 14, 2025
7694dac
Fixed lint errors
Feb 14, 2025
cae31ae
Fixed lint errors
Feb 14, 2025
4003193
Fixed lint errors
Feb 14, 2025
bc3fb27
Make variables for CSV export null-proof
Feb 14, 2025
f905096
Merge branch 'DSpace:main' into main
jeffmorin Feb 17, 2025
417b289
Attempt to fix unit tests
Feb 17, 2025
9099c3a
Fixed styling errors
Feb 17, 2025
b1ef0bc
Fixed script references in unit tests
Feb 17, 2025
b304488
Fixed typo in script name
Feb 17, 2025
43a7f10
Fixed test parameterization
Feb 17, 2025
6db3d41
Parameterization attempt
Feb 17, 2025
cd50fb2
Parameterization test
Feb 17, 2025
f543e07
Parameterization rollback
Feb 17, 2025
80c88b1
Fixed predicate encoding bug
Feb 17, 2025
3fa60ca
Parameterization test
Feb 17, 2025
e23e6ac
Fixed styling error
Feb 17, 2025
d091830
Fixed query predicate parameter
Feb 17, 2025
8ff97c2
Fixed collection parameterization
Feb 17, 2025
d918de7
Centralized string representation of a predicate
Feb 17, 2025
a2cf151
Fixed parameterization
Feb 17, 2025
51cb5bf
Fixed second export test
Feb 18, 2025
058eaa0
Replaced null payload by an empty non-null one
Feb 18, 2025
e461105
Merge branch 'DSpace:main' into main
jeffmorin Feb 27, 2025
8945689
Merge branch 'DSpace:main' into main
jeffmorin Mar 3, 2025
d31fd5f
Merge branch 'DSpace:main' into main
jeffmorin Mar 6, 2025
207c6d8
Merge branch 'DSpace:main' into main
jeffmorin Mar 10, 2025
88532fd
Merge branch 'DSpace:main' into main
jeffmorin Mar 10, 2025
6999753
Requested changes
Mar 10, 2025
c5fc638
Fixed remaining bugs
Mar 10, 2025
ba12b39
Resolved conflicts
Mar 11, 2025
8444ea9
Updated Angular control flow syntax
Mar 11, 2025
1817f42
Improved collection parameter handling
Mar 12, 2025
172e6f3
Fixed styling error
Mar 12, 2025
a2a7a13
Updated config.yml to match the central dspace-angular repo
jeffmorin Mar 12, 2025
6d079f6
Merge branch 'DSpace:main' into main
jeffmorin Mar 13, 2025
b221ef3
Removed repeated content
Mar 13, 2025
d2d202a
Merge branch 'main' of https://github.com/jeffmorin/dspace-angular
Mar 13, 2025
6499913
Cleaned up a now useless import
Mar 13, 2025
e2a18d1
Merge branch 'DSpace:main' into main
jeffmorin Mar 14, 2025
a3f5789
Resolved merge conflicts
Mar 17, 2025
a7a4c79
Merge branch 'DSpace:main' into main
jeffmorin Mar 17, 2025
4af6310
Fixed collections loading and added warning message about CSV export
Mar 20, 2025
ed7ebd2
Fixed styling error
Mar 20, 2025
1fcfcac
Forgot to clean up old code
Mar 20, 2025
f1c0847
Merge branch 'DSpace:main' into main
jeffmorin Mar 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@if (shouldShowButton$ | async) {
<button 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>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.export-button {
background: var(--ds-admin-sidebar-bg);
border-color: var(--ds-admin-sidebar-bg);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
ComponentFixture,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
FormControl,
FormGroup,
} from '@angular/forms';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';

import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
import { Process } from '../../../../process-page/processes/process.model';
import { Script } from '../../../../process-page/scripts/script.model';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject$,
} from '../../../../shared/remote-data.utils';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { FiltersComponent } from '../../filters-section/filters-section.component';
import { OptionVO } from '../option-vo.model';
import { QueryPredicate } from '../query-predicate.model';
import { FilteredItemsExportCsvComponent } from './filtered-items-export-csv.component';

describe('FilteredItemsExportCsvComponent', () => {
let component: FilteredItemsExportCsvComponent;
let fixture: ComponentFixture<FilteredItemsExportCsvComponent>;

let scriptDataService: ScriptDataService;
let authorizationDataService: AuthorizationDataService;
let notificationsService;
let router;

const script = Object.assign(new Script(), { id: 'metadata-export-filtered-items-report', name: 'metadata-export-filtered-items-report' });
const process = Object.assign(new Process(), { processId: 5, scriptName: 'metadata-export-filtered-items-report' });

const params = new FormGroup({
collections: new FormControl([OptionVO.collection('1', 'coll1')]),
queryPredicates: new FormControl([QueryPredicate.of('name', 'equals', 'coll1')]),
filters: new FormControl([FiltersComponent.getFilter('is_item')]),
});

const emptyParams = new FormGroup({
collections: new FormControl([]),
queryPredicates: new FormControl([]),
filters: new FormControl([]),
});

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({
imports: [TranslateModule.forRoot(), NgbModule, FilteredItemsExportCsvComponent],
providers: [
{ provide: ScriptDataService, useValue: scriptDataService },
{ provide: AuthorizationDataService, useValue: authorizationDataService },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: Router, useValue: router },
],
}).compileComponents();
}

function initBeforeEach() {
fixture = TestBed.createComponent(FilteredItemsExportCsvComponent);
component = fixture.componentInstance;
component.reportParams = params;
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-filtered-items-report 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-filtered-items-report 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', () => {
// Parameterized export
component.export();
expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-filtered-items-report',
[
{ name: '-c', value: params.value.collections[0].id },
{ name: '-qp', value: QueryPredicate.toString(params.value.queryPredicates[0]) },
{ name: '-f', value: FiltersComponent.toQueryString(params.value.filters) },
], []);

fixture.detectChanges();

// Non-parameterized export
component.reportParams = emptyParams;
fixture.detectChanges();
component.export();
expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-filtered-items-report', [], []);

});
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();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { AsyncPipe } from '@angular/common';
import {
Component,
Input,
OnInit,
} from '@angular/core';
import { FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
combineLatest as observableCombineLatest,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';

import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
import { RemoteData } from '../../../../core/data/remote-data';
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
import { Process } from '../../../../process-page/processes/process.model';
import { hasValue } from '../../../../shared/empty.util';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { FiltersComponent } from '../../filters-section/filters-section.component';
import { OptionVO } from '../option-vo.model';
import { QueryPredicate } from '../query-predicate.model';

@Component({
selector: 'ds-filtered-items-export-csv',
styleUrls: ['./filtered-items-export-csv.component.scss'],
templateUrl: './filtered-items-export-csv.component.html',
standalone: true,
imports: [NgbTooltipModule, AsyncPipe, TranslateModule],
})
/**
* Display a button to export the MetadataQuery (aka Filtered Items) Report results as csv
*/
export class FilteredItemsExportCsvComponent implements OnInit {

/**
* The current configuration of the search
*/
@Input() reportParams: FormGroup;

/**
* 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-filtered-items.tooltip';

constructor(private scriptDataService: ScriptDataService,
private authorizationDataService: AuthorizationDataService,
private notificationsService: NotificationsService,
private translateService: TranslateService,
private router: Router,
) {
}

static csvExportEnabled(scriptDataService: ScriptDataService, authorizationDataService: AuthorizationDataService): Observable<boolean> {
const scriptExists$ = scriptDataService.findById('metadata-export-filtered-items-report').pipe(
getFirstCompletedRemoteData(),
map((rd) => rd.isSuccess && hasValue(rd.payload)),
);

const isAuthorized$ = authorizationDataService.isAuthorized(FeatureID.AdministratorOf);

return observableCombineLatest([scriptExists$, isAuthorized$]).pipe(
map(([scriptExists, isAuthorized]: [boolean, boolean]) => scriptExists && isAuthorized),
);
}

ngOnInit(): void {
this.shouldShowButton$ = FilteredItemsExportCsvComponent.csvExportEnabled(this.scriptDataService, this.authorizationDataService);
}

/**
* Start the export of the items based on the selected parameters
*/
export() {
const parameters = [];
const colls = this.reportParams.value.collections || [];
for (let i = 0; i < colls.length; i++) {
if (colls[i]) {
parameters.push({ name: '-c', value: OptionVO.toString(colls[i]) });
}
}

const preds = this.reportParams.value.queryPredicates || [];
for (let i = 0; i < preds.length; i++) {
const field = preds[i].field;
const op = preds[i].operator;
if (field && op) {
parameters.push({ name: '-qp', value: QueryPredicate.toString(preds[i]) });
}
}

const filters = FiltersComponent.toQueryString(this.reportParams.value.filters) || [];
if (filters.length > 0) {
parameters.push({ name: '-f', value: filters });
}

this.scriptDataService.invoke('metadata-export-filtered-items-report', parameters, []).pipe(
getFirstCompletedRemoteData(),
).subscribe((rd: RemoteData<Process>) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('metadata-export-filtered-items.submit.success'));
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
} else {
this.notificationsService.error(this.translateService.get('metadata-export-filtered-items.submit.error'));
}
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ <h1 id="header" class="border-bottom pb-2">{{'admin.reports.items.head' | transl
{{'admin.reports.items.section.collectionSelector' | translate}}
</ng-template>
<ng-template ngbPanelContent>
<select id="collSel" name="collSel" class="form-control" multiple="multiple" size="10" formControlName="collections">
@for (item of collections; track item) {
<option [value]="item.id" [disabled]="item.disabled">{{item.name$ | async}}</option>
}
</select>
@if (loadingCollections$ | async) {
<ds-loading></ds-loading>
}
@if ((loadingCollections$ | async) !== true) {
<select id="collSel" name="collSel" class="form-control" multiple="multiple" size="10" formControlName="collections">
@for (item of collections; track item) {
<option [value]="item.id" [disabled]="item.disabled">{{item.name$ | async}}</option>
}
</select>
}
<div class="row">
<span class="col-3"></span>
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
Expand Down Expand Up @@ -132,6 +137,10 @@ <h1 id="header" class="border-bottom pb-2">{{'admin.reports.items.head' | transl
</select>
</div>
<div class="row">
@if (csvExportEnabled$ | async) {
<span class="col-3"></span>
<div class="warning">{{ 'metadata-export-filtered-items.columns.warning' | translate }}</div>
}
<span class="col-3"></span>
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
</div>
Expand Down Expand Up @@ -186,9 +195,9 @@ <h1 id="header" class="border-bottom pb-2">{{'admin.reports.items.head' | transl
<div>
<button id="prev" class="btn btn-light" (click)="prevPage()" [dsBtnDisabled]="!canNavigatePrevious()">{{'admin.reports.commons.previous-page' | translate}}</button>
<button id="next" class="btn btn-light" (click)="nextPage()" [dsBtnDisabled]="!canNavigateNext()">{{'admin.reports.commons.next-page' | translate}}</button>
<!--
<button id="export">{{'admin.reports.commons.export' | translate}}</button>
-->
<div style="float: right; margin-right: 60px;">
<ds-filtered-items-export-csv [reportParams]="queryForm"></ds-filtered-items-export-csv>
</div>
</div>
<table id="itemtable" class="sortable"></table>
</ng-template>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
.num {
text-align: center;
}

.warning {
color: red;
font-style: italic;
text-align: center;
width: 100%;
}
Loading
Loading