diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.html b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.html new file mode 100644 index 0000000000..a8f5463ce1 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.html @@ -0,0 +1,8 @@ +@if (shouldShowButton$ | async) { + + + +} \ No newline at end of file diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.scss b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.scss new file mode 100644 index 0000000000..4b0ab3c44a --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.scss @@ -0,0 +1,4 @@ +.export-button { + background: var(--ds-admin-sidebar-bg); + border-color: var(--ds-admin-sidebar-bg); +} \ No newline at end of file diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.spec.ts b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.spec.ts new file mode 100644 index 0000000000..d9627dff70 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.spec.ts @@ -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; + + 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(); + }); + }); +}); diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.ts b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.ts new file mode 100644 index 0000000000..50a0ca32b7 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-export-csv/filtered-items-export-csv.component.ts @@ -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; + + /** + * 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 { + 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) => { + 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')); + } + }); + } + +} diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.html b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html index 6b67a12769..dd3f45c216 100644 --- a/src/app/admin/admin-reports/filtered-items/filtered-items.component.html +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html @@ -11,11 +11,16 @@ {{'admin.reports.items.section.collectionSelector' | translate}} - - @for (item of collections; track item) { - {{item.name$ | async}} - } - + @if (loadingCollections$ | async) { + + } + @if ((loadingCollections$ | async) !== true) { + + @for (item of collections; track item) { + {{item.name$ | async}} + } + + } {{'admin.reports.items.run' | translate}} @@ -132,6 +137,10 @@ + @if (csvExportEnabled$ | async) { + + {{ 'metadata-export-filtered-items.columns.warning' | translate }} + } {{'admin.reports.items.run' | translate}} @@ -186,9 +195,9 @@ {{'admin.reports.commons.previous-page' | translate}} {{'admin.reports.commons.next-page' | translate}} - + + + diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss b/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss index 73ce5275e5..15e8b54bc7 100644 --- a/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss @@ -1,3 +1,10 @@ .num { text-align: center; } + +.warning { + color: red; + font-style: italic; + text-align: center; + width: 100%; +} \ No newline at end of file diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts index 7782b0e416..1daea4168e 100644 --- a/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts @@ -20,13 +20,16 @@ import { TranslateService, } from '@ngx-translate/core'; import { + BehaviorSubject, map, Observable, } from 'rxjs'; import { CollectionDataService } from 'src/app/core/data/collection-data.service'; import { CommunityDataService } from 'src/app/core/data/community-data.service'; +import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service'; import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service'; import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service'; +import { ScriptDataService } from 'src/app/core/data/processes/script-data.service'; import { RestRequestMethod } from 'src/app/core/data/rest-request-method'; import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service'; import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model'; @@ -36,10 +39,12 @@ import { Collection } from 'src/app/core/shared/collection.model'; import { Community } from 'src/app/core/shared/community.model'; import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operators'; import { isEmpty } from 'src/app/shared/empty.util'; +import { ThemedLoadingComponent } from 'src/app/shared/loading/themed-loading.component'; import { environment } from 'src/environments/environment'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { FiltersComponent } from '../filters-section/filters-section.component'; +import { FilteredItemsExportCsvComponent } from './filtered-items-export-csv/filtered-items-export-csv.component'; import { FilteredItem, FilteredItems, @@ -62,12 +67,19 @@ import { QueryPredicate } from './query-predicate.model'; AsyncPipe, FiltersComponent, BtnDisabledDirective, + FilteredItemsExportCsvComponent, + ThemedLoadingComponent, ], standalone: true, }) export class FilteredItemsComponent implements OnInit { collections: OptionVO[]; + /** + * A Boolean representing if loading the list of collections is pending + */ + loadingCollections$: BehaviorSubject = new BehaviorSubject(false); + presetQueries: PresetQuery[]; metadataFields: OptionVO[]; metadataFieldsWithAny: OptionVO[]; @@ -79,6 +91,10 @@ export class FilteredItemsComponent implements OnInit { results: FilteredItems = new FilteredItems(); results$: Observable; @ViewChild('acc') accordionComponent: NgbAccordion; + /** + * Observable used to determine whether CSV export is enabled + */ + csvExportEnabled$: Observable; constructor( private communityService: CommunityDataService, @@ -86,6 +102,8 @@ export class FilteredItemsComponent implements OnInit { private metadataSchemaService: MetadataSchemaDataService, private metadataFieldService: MetadataFieldDataService, private translateService: TranslateService, + private scriptDataService: ScriptDataService, + private authorizationDataService: AuthorizationDataService, private formBuilder: FormBuilder, private restService: DspaceRestService) {} @@ -100,6 +118,8 @@ export class FilteredItemsComponent implements OnInit { new QueryPredicate().toFormGroup(this.formBuilder), ]; + this.csvExportEnabled$ = FilteredItemsExportCsvComponent.csvExportEnabled(this.scriptDataService, this.authorizationDataService); + this.queryForm = this.formBuilder.group({ collections: this.formBuilder.control([''], []), presetQuery: this.formBuilder.control('new', []), @@ -111,6 +131,7 @@ export class FilteredItemsComponent implements OnInit { } loadCollections(): void { + this.loadingCollections$.next(true); this.collections = []; const wholeRepo$ = this.translateService.stream('admin.reports.items.wholeRepo'); this.collections.push(OptionVO.collectionLoc('', wholeRepo$)); @@ -132,6 +153,7 @@ export class FilteredItemsComponent implements OnInit { const collVO = OptionVO.collection(collection.uuid, '–' + collection.name); this.collections.push(collVO); }); + this.loadingCollections$.next(false); }, ); }); @@ -167,10 +189,10 @@ export class FilteredItemsComponent implements OnInit { QueryPredicate.of('dc.description.provenance', QueryPredicate.DOES_NOT_MATCH, '^.*No\. of bitstreams(.|\r|\n|\r\n)*\.(PDF|pdf|DOC|doc|PPT|ppt|DOCX|docx|PPTX|pptx).*$'), ]), PresetQuery.of('q9', 'admin.reports.items.preset.hasEmptyMetadata', [ - QueryPredicate.of('*', QueryPredicate.MATCHES, '^\s*$'), + QueryPredicate.of('*', QueryPredicate.MATCHES, '^\\s*$'), ]), PresetQuery.of('q10', 'admin.reports.items.preset.hasUnbreakingDataInDescription', [ - QueryPredicate.of('dc.description.*', QueryPredicate.MATCHES, '^.*[^\s]{50,}.*$'), + QueryPredicate.of('dc.description.*', QueryPredicate.MATCHES, '^.*(\\S){50,}.*$'), ]), PresetQuery.of('q12', 'admin.reports.items.preset.hasXmlEntityInMetadata', [ QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*.*$'), @@ -344,13 +366,8 @@ export class FilteredItemsComponent implements OnInit { const preds = this.queryForm.value.queryPredicates; for (let i = 0; i < preds.length; i++) { - const field = preds[i].field; - const op = preds[i].operator; - const value = preds[i].value; - params += `&queryPredicates=${field}:${op}`; - if (value) { - params += `:${value}`; - } + const pred = encodeURIComponent(QueryPredicate.toString(preds[i])); + params += `&queryPredicates=${pred}`; } const filters = FiltersComponent.toQueryString(this.queryForm.value.filters); diff --git a/src/app/admin/admin-reports/filtered-items/option-vo.model.ts b/src/app/admin/admin-reports/filtered-items/option-vo.model.ts index b26a42a8d8..a598fb9a3b 100644 --- a/src/app/admin/admin-reports/filtered-items/option-vo.model.ts +++ b/src/app/admin/admin-reports/filtered-items/option-vo.model.ts @@ -46,6 +46,16 @@ export class OptionVO { subscriber.next(value); subscriber.complete(); }); - } + + static toString(obj: any): string { + if (obj) { + if (obj instanceof OptionVO && obj.id) { + return obj.id; + } + return obj as string; + } + return ''; + } + } diff --git a/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts b/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts index 1c12d72e27..1c91bfa744 100644 --- a/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts +++ b/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts @@ -29,6 +29,13 @@ export class QueryPredicate { return pred; } + static toString(pred: QueryPredicate): string { + if (pred.value) { + return `${pred.field}:${pred.operator}:${pred.value}`; + } + return `${pred.field}:${pred.operator}`; + } + toFormGroup(formBuilder: FormBuilder): FormGroup { return formBuilder.group({ field: new FormControl(this.field), diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 039b503d0e..893785c5a6 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -6861,4 +6861,12 @@ "search.filters.access_status.metadata.only": "Metadata only", "search.filters.access_status.unknown": "Unknown", + + "metadata-export-filtered-items.tooltip": "Export report output as CSV", + + "metadata-export-filtered-items.submit.success": "CSV export succeeded.", + + "metadata-export-filtered-items.submit.error": "CSV export failed.", + + "metadata-export-filtered-items.columns.warning": "CSV export automatically includes all relevant fields, so selections in this list are not taken into account.", } diff --git a/src/assets/i18n/fr.json5 b/src/assets/i18n/fr.json5 index be88a39c9b..9e8866c47d 100644 --- a/src/assets/i18n/fr.json5 +++ b/src/assets/i18n/fr.json5 @@ -8584,4 +8584,15 @@ //"search.filters.access_status.unknown": "Unknown", "search.filters.access_status.unknown": "Inconnu", + //"metadata-export-filtered-items.tooltip": "Export report output as CSV", + "metadata-export-filtered-items.tooltip": "Exporter le rapport en CSV", + + //"metadata-export-filtered-items.submit.success": "CSV export succeeded.", + "metadata-export-filtered-items.submit.success": "Exportation CSV complétée.", + + //"metadata-export-filtered-items.submit.error": "CSV export failed.", + "metadata-export-filtered-items.submit.error": "L'exportation CSV n'a pas fonctionné.", + + //"metadata-export-filtered-items.columns.warning": "CSV export automatically includes all relevant fields, so selections in this list are not taken into account.", + "metadata-export-filtered-items.columns.warning": "L'exportation CSV inclut automatiquement tous les champs pertinents, sans égard au contenu sélectionné de cette liste.", }