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 00000000000..a8f5463ce18
--- /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 00000000000..4b0ab3c44ad
--- /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 00000000000..d9627dff701
--- /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 00000000000..50a0ca32b7d
--- /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 6b67a12769b..dd3f45c216d 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 @@