diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.html b/src/app/admin/admin-import-batch-page/batch-import-page.component.html
new file mode 100644
index 0000000000..dbc8c74437
--- /dev/null
+++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.html
@@ -0,0 +1,35 @@
+
+
+
{{'admin.batch-import.page.help' | translate}}
+
+ selected collection: {{getDspaceObjectName()}}
+ {{'admin.batch-import.page.remove' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts
new file mode 100644
index 0000000000..36ba1137c9
--- /dev/null
+++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts
@@ -0,0 +1,151 @@
+import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
+import { BatchImportPageComponent } from './batch-import-page.component';
+import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
+import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { FormsModule } from '@angular/forms';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive';
+import { FileValidator } from '../../shared/utils/require-file.validator';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import {
+ BATCH_IMPORT_SCRIPT_NAME,
+ ScriptDataService
+} from '../../core/data/processes/script-data.service';
+import { Router } from '@angular/router';
+import { Location } from '@angular/common';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { By } from '@angular/platform-browser';
+import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
+
+describe('BatchImportPageComponent', () => {
+ let component: BatchImportPageComponent;
+ let fixture: ComponentFixture;
+
+ let notificationService: NotificationsServiceStub;
+ let scriptService: any;
+ let router;
+ let locationStub;
+
+ function init() {
+ notificationService = new NotificationsServiceStub();
+ scriptService = jasmine.createSpyObj('scriptService',
+ {
+ invoke: createSuccessfulRemoteDataObject$({ processId: '46' })
+ }
+ );
+ router = jasmine.createSpyObj('router', {
+ navigateByUrl: jasmine.createSpy('navigateByUrl')
+ });
+ locationStub = jasmine.createSpyObj('location', {
+ back: jasmine.createSpy('back')
+ });
+ }
+
+ beforeEach(waitForAsync(() => {
+ init();
+ TestBed.configureTestingModule({
+ imports: [
+ FormsModule,
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([])
+ ],
+ declarations: [BatchImportPageComponent, FileValueAccessorDirective, FileValidator],
+ providers: [
+ { provide: NotificationsService, useValue: notificationService },
+ { provide: ScriptDataService, useValue: scriptService },
+ { provide: Router, useValue: router },
+ { provide: Location, useValue: locationStub },
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BatchImportPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('if back button is pressed', () => {
+ beforeEach(fakeAsync(() => {
+ const proceed = fixture.debugElement.query(By.css('#backButton')).nativeElement;
+ proceed.click();
+ fixture.detectChanges();
+ }));
+ it('should do location.back', () => {
+ expect(locationStub.back).toHaveBeenCalled();
+ });
+ });
+
+ describe('if file is set', () => {
+ let fileMock: File;
+
+ beforeEach(() => {
+ fileMock = new File([''], 'filename.zip', { type: 'application/zip' });
+ component.setFile(fileMock);
+ });
+
+ describe('if proceed button is pressed without validate only', () => {
+ beforeEach(fakeAsync(() => {
+ component.validateOnly = false;
+ const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
+ proceed.click();
+ fixture.detectChanges();
+ }));
+ it('metadata-import script is invoked with --zip fileName and the mockFile', () => {
+ const parameterValues: ProcessParameter[] = [
+ Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }),
+ ];
+ parameterValues.push(Object.assign(new ProcessParameter(), { name: '--add' }));
+ expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]);
+ });
+ it('success notification is shown', () => {
+ expect(notificationService.success).toHaveBeenCalled();
+ });
+ it('redirected to process page', () => {
+ expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46');
+ });
+ });
+
+ describe('if proceed button is pressed with validate only', () => {
+ beforeEach(fakeAsync(() => {
+ component.validateOnly = true;
+ const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
+ proceed.click();
+ fixture.detectChanges();
+ }));
+ it('metadata-import script is invoked with --zip fileName and the mockFile and -v validate-only', () => {
+ const parameterValues: ProcessParameter[] = [
+ Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }),
+ Object.assign(new ProcessParameter(), { name: '--add' }),
+ Object.assign(new ProcessParameter(), { name: '-v', value: true }),
+ ];
+ expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]);
+ });
+ it('success notification is shown', () => {
+ expect(notificationService.success).toHaveBeenCalled();
+ });
+ it('redirected to process page', () => {
+ expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46');
+ });
+ });
+
+ describe('if proceed is pressed; but script invoke fails', () => {
+ beforeEach(fakeAsync(() => {
+ jasmine.getEnv().allowRespy(true);
+ spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500));
+ const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
+ proceed.click();
+ fixture.detectChanges();
+ }));
+ it('error notification is shown', () => {
+ expect(notificationService.error).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts
new file mode 100644
index 0000000000..7171c67585
--- /dev/null
+++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts
@@ -0,0 +1,124 @@
+import { Component } from '@angular/core';
+import { Location } from '@angular/common';
+import { TranslateService } from '@ngx-translate/core';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { BATCH_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service';
+import { Router } from '@angular/router';
+import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
+import { getFirstCompletedRemoteData } from '../../core/shared/operators';
+import { RemoteData } from '../../core/data/remote-data';
+import { Process } from '../../process-page/processes/process.model';
+import { isNotEmpty } from '../../shared/empty.util';
+import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths';
+import {
+ ImportBatchSelectorComponent
+} from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component';
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+import { take } from 'rxjs/operators';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
+
+@Component({
+ selector: 'ds-batch-import-page',
+ templateUrl: './batch-import-page.component.html'
+})
+export class BatchImportPageComponent {
+ /**
+ * The current value of the file
+ */
+ fileObject: File;
+
+ /**
+ * The validate only flag
+ */
+ validateOnly = true;
+ /**
+ * dso object for community or collection
+ */
+ dso: DSpaceObject = null;
+
+ public constructor(private location: Location,
+ protected translate: TranslateService,
+ protected notificationsService: NotificationsService,
+ private scriptDataService: ScriptDataService,
+ private router: Router,
+ private modalService: NgbModal,
+ private dsoNameService: DSONameService) {
+ }
+
+ /**
+ * Set file
+ * @param file
+ */
+ setFile(file) {
+ this.fileObject = file;
+ }
+
+ /**
+ * When return button is pressed go to previous location
+ */
+ public onReturn() {
+ this.location.back();
+ }
+
+ public selectCollection() {
+ const modalRef = this.modalService.open(ImportBatchSelectorComponent);
+ modalRef.componentInstance.response.pipe(take(1)).subscribe((dso) => {
+ this.dso = dso || null;
+ });
+ }
+
+ /**
+ * Starts import-metadata script with --zip fileName (and the selected file)
+ */
+ public importMetadata() {
+ if (this.fileObject == null) {
+ this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile'));
+ } else {
+ const parameterValues: ProcessParameter[] = [
+ Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }),
+ Object.assign(new ProcessParameter(), { name: '--add' })
+ ];
+ if (this.dso) {
+ parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid }));
+ }
+ if (this.validateOnly) {
+ parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true }));
+ }
+
+ this.scriptDataService.invoke(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe(
+ getFirstCompletedRemoteData(),
+ ).subscribe((rd: RemoteData) => {
+ if (rd.hasSucceeded) {
+ const title = this.translate.get('process.new.notification.success.title');
+ const content = this.translate.get('process.new.notification.success.content');
+ this.notificationsService.success(title, content);
+ if (isNotEmpty(rd.payload)) {
+ this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
+ }
+ } else {
+ const title = this.translate.get('process.new.notification.error.title');
+ const content = this.translate.get('process.new.notification.error.content');
+ this.notificationsService.error(title, content);
+ }
+ });
+ }
+ }
+
+ /**
+ * return selected dspace object name
+ */
+ getDspaceObjectName(): string {
+ if (this.dso) {
+ return this.dsoNameService.getName(this.dso);
+ }
+ return null;
+ }
+
+ /**
+ * remove selected dso object
+ */
+ removeDspaceObject(): void {
+ this.dso = null;
+ }
+}
diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts
index ee5cb8737b..1ea20bc9a0 100644
--- a/src/app/admin/admin-routing.module.ts
+++ b/src/app/admin/admin-routing.module.ts
@@ -7,6 +7,7 @@ import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component';
import { REGISTRIES_MODULE_PATH } from './admin-routing-paths';
+import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
@NgModule({
imports: [
@@ -40,6 +41,12 @@ import { REGISTRIES_MODULE_PATH } from './admin-routing-paths';
component: MetadataImportPageComponent,
data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' }
},
+ {
+ path: 'batch-import',
+ resolve: { breadcrumb: I18nBreadcrumbResolver },
+ component: BatchImportPageComponent,
+ data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }
+ },
])
],
providers: [
diff --git a/src/app/admin/admin.module.ts b/src/app/admin/admin.module.ts
index b28a0cf89e..0ddbefd253 100644
--- a/src/app/admin/admin.module.ts
+++ b/src/app/admin/admin.module.ts
@@ -9,6 +9,7 @@ import { AdminWorkflowModuleModule } from './admin-workflow-page/admin-workflow.
import { AdminSearchModule } from './admin-search-page/admin-search.module';
import { AdminSidebarSectionComponent } from './admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
import { ExpandableAdminSidebarSectionComponent } from './admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
+import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -28,7 +29,8 @@ const ENTRY_COMPONENTS = [
],
declarations: [
AdminCurationTasksComponent,
- MetadataImportPageComponent
+ MetadataImportPageComponent,
+ BatchImportPageComponent
]
})
export class AdminModule {
diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts
index ed228612ef..d9c92cb1d2 100644
--- a/src/app/core/data/processes/script-data.service.ts
+++ b/src/app/core/data/processes/script-data.service.ts
@@ -24,6 +24,8 @@ import { dataService } from '../base/data-service.decorator';
export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import';
export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export';
+export const BATCH_IMPORT_SCRIPT_NAME = 'import';
+export const BATCH_EXPORT_SCRIPT_NAME = 'export';
@Injectable()
@dataService(SCRIPT)
diff --git a/src/app/menu.resolver.spec.ts b/src/app/menu.resolver.spec.ts
index db90b7ea00..4fd44efe66 100644
--- a/src/app/menu.resolver.spec.ts
+++ b/src/app/menu.resolver.spec.ts
@@ -259,9 +259,15 @@ describe('MenuResolver', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'import', visible: true,
}));
+ expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
+ id: 'import_batch', parentID: 'import', visible: true,
+ }));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export', visible: true,
}));
+ expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
+ id: 'export_batch', parentID: 'export', visible: true,
+ }));
});
});
diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts
index f12079f737..8630150c58 100644
--- a/src/app/menu.resolver.ts
+++ b/src/app/menu.resolver.ts
@@ -44,6 +44,9 @@ import {
METADATA_IMPORT_SCRIPT_NAME,
ScriptDataService
} from './core/data/processes/script-data.service';
+import {
+ ExportBatchSelectorComponent
+} from './shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component';
/**
* Creates all of the app's menus
@@ -440,6 +443,20 @@ export class MenuResolver implements Resolve {
} as OnClickMenuItemModel,
shouldPersistOnRouteChange: true
});
+ this.menuService.addSection(MenuID.ADMIN, {
+ id: 'export_batch',
+ parentID: 'export',
+ active: false,
+ visible: true,
+ model: {
+ type: MenuItemType.ONCLICK,
+ text: 'menu.section.export_batch',
+ function: () => {
+ this.modalService.open(ExportBatchSelectorComponent);
+ }
+ } as OnClickMenuItemModel,
+ shouldPersistOnRouteChange: true
+ });
});
}
@@ -448,20 +465,7 @@ export class MenuResolver implements Resolve {
* the import scripts exist and the current user is allowed to execute them
*/
createImportMenuSections() {
- const menuList = [
- // TODO: enable this menu item once the feature has been implemented
- // {
- // id: 'import_batch',
- // parentID: 'import',
- // active: false,
- // visible: true,
- // model: {
- // type: MenuItemType.LINK,
- // text: 'menu.section.import_batch',
- // link: ''
- // } as LinkMenuItemModel,
- // }
- ];
+ const menuList = [];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, menuSection));
observableCombineLatest([
@@ -498,6 +502,18 @@ export class MenuResolver implements Resolve {
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
});
+ this.menuService.addSection(MenuID.ADMIN, {
+ id: 'import_batch',
+ parentID: 'import',
+ active: false,
+ visible: true,
+ model: {
+ type: MenuItemType.LINK,
+ text: 'menu.section.import_batch',
+ link: '/admin/batch-import'
+ } as LinkMenuItemModel,
+ shouldPersistOnRouteChange: true
+ });
});
}
diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts
index ca8343cfad..113ca518fd 100644
--- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts
+++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts
@@ -10,7 +10,9 @@ export enum SelectorActionType {
CREATE = 'create',
EDIT = 'edit',
EXPORT_METADATA = 'export-metadata',
- SET_SCOPE = 'set-scope'
+ IMPORT_BATCH = 'import-batch',
+ SET_SCOPE = 'set-scope',
+ EXPORT_BATCH = 'export-batch'
}
/**
diff --git a/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.spec.ts
new file mode 100644
index 0000000000..18ec6007ea
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.spec.ts
@@ -0,0 +1,210 @@
+import { of as observableOf } from 'rxjs';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { DebugElement, NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
+import { NgbActiveModal, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
+import { ActivatedRoute, Router } from '@angular/router';
+import { BATCH_EXPORT_SCRIPT_NAME, ScriptDataService } from '../../../../core/data/processes/script-data.service';
+import { Collection } from '../../../../core/shared/collection.model';
+import { Item } from '../../../../core/shared/item.model';
+import { ProcessParameter } from '../../../../process-page/processes/process-parameter.model';
+import { ConfirmationModalComponent } from '../../../confirmation-modal/confirmation-modal.component';
+import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock';
+import { NotificationsService } from '../../../notifications/notifications.service';
+import { NotificationsServiceStub } from '../../../testing/notifications-service.stub';
+import {
+ createFailedRemoteDataObject$,
+ createSuccessfulRemoteDataObject,
+ createSuccessfulRemoteDataObject$
+} from '../../../remote-data.utils';
+import { ExportBatchSelectorComponent } from './export-batch-selector.component';
+import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
+
+// No way to add entryComponents yet to testbed; alternative implemented; source: https://stackoverflow.com/questions/41689468/how-to-shallow-test-a-component-with-an-entrycomponents
+@NgModule({
+ imports: [NgbModalModule,
+ TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: TranslateLoaderMock
+ }
+ }),
+ ],
+ exports: [],
+ declarations: [ConfirmationModalComponent],
+ providers: []
+})
+class ModelTestModule {
+}
+
+describe('ExportBatchSelectorComponent', () => {
+ let component: ExportBatchSelectorComponent;
+ let fixture: ComponentFixture;
+ let debugElement: DebugElement;
+ let modalRef;
+
+ let router;
+ let notificationService: NotificationsServiceStub;
+ let scriptService;
+ let authorizationDataService;
+
+ const mockItem = Object.assign(new Item(), {
+ id: 'fake-id',
+ uuid: 'fake-id',
+ handle: 'fake/handle',
+ lastModified: '2018'
+ });
+
+ const mockCollection: Collection = Object.assign(new Collection(), {
+ id: 'test-collection-1-1',
+ uuid: 'test-collection-1-1',
+ name: 'test-collection-1',
+ metadata: {
+ 'dc.identifier.uri': [
+ {
+ language: null,
+ value: 'fake/test-collection-1'
+ }
+ ]
+ }
+ });
+ const itemRD = createSuccessfulRemoteDataObject(mockItem);
+ const modalStub = jasmine.createSpyObj('modalStub', ['close']);
+
+ beforeEach(waitForAsync(() => {
+ notificationService = new NotificationsServiceStub();
+ router = jasmine.createSpyObj('router', {
+ navigateByUrl: jasmine.createSpy('navigateByUrl')
+ });
+ scriptService = jasmine.createSpyObj('scriptService',
+ {
+ invoke: createSuccessfulRemoteDataObject$({ processId: '45' })
+ }
+ );
+ authorizationDataService = jasmine.createSpyObj('authorizationDataService', {
+ isAuthorized: observableOf(true)
+ });
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), ModelTestModule],
+ declarations: [ExportBatchSelectorComponent],
+ providers: [
+ { provide: NgbActiveModal, useValue: modalStub },
+ { provide: NotificationsService, useValue: notificationService },
+ { provide: ScriptDataService, useValue: scriptService },
+ { provide: AuthorizationDataService, useValue: authorizationDataService },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ root: {
+ snapshot: {
+ data: {
+ dso: itemRD,
+ },
+ },
+ }
+ },
+ },
+ {
+ provide: Router, useValue: router
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ExportBatchSelectorComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ const modalService = TestBed.inject(NgbModal);
+ modalRef = modalService.open(ConfirmationModalComponent);
+ modalRef.componentInstance.response = observableOf(true);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('if item is selected', () => {
+ let scriptRequestSucceeded;
+ beforeEach((done) => {
+ component.navigate(mockItem).subscribe((succeeded: boolean) => {
+ scriptRequestSucceeded = succeeded;
+ done();
+ });
+ });
+ it('should not invoke batch-export script', () => {
+ expect(scriptService.invoke).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('if collection is selected and is admin', () => {
+ let scriptRequestSucceeded;
+ beforeEach((done) => {
+ spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
+ component.navigate(mockCollection).subscribe((succeeded: boolean) => {
+ scriptRequestSucceeded = succeeded;
+ done();
+ });
+ });
+ it('should invoke the batch-export script with option --id uuid option', () => {
+ const parameterValues: ProcessParameter[] = [
+ Object.assign(new ProcessParameter(), { name: '--id', value: mockCollection.uuid }),
+ Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' }),
+ ];
+ expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
+ });
+ it('success notification is shown', () => {
+ expect(scriptRequestSucceeded).toBeTrue();
+ expect(notificationService.success).toHaveBeenCalled();
+ });
+ it('redirected to process page', () => {
+ expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45');
+ });
+ });
+ describe('if collection is selected and is not admin', () => {
+ let scriptRequestSucceeded;
+ beforeEach((done) => {
+ (authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
+ spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
+ component.navigate(mockCollection).subscribe((succeeded: boolean) => {
+ scriptRequestSucceeded = succeeded;
+ done();
+ });
+ });
+ it('should invoke the Batch-export script with option --id uuid without option', () => {
+ const parameterValues: ProcessParameter[] = [
+ Object.assign(new ProcessParameter(), { name: '--id', value: mockCollection.uuid }),
+ Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' })
+ ];
+ expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
+ });
+ it('success notification is shown', () => {
+ expect(scriptRequestSucceeded).toBeTrue();
+ expect(notificationService.success).toHaveBeenCalled();
+ });
+ it('redirected to process page', () => {
+ expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45');
+ });
+ });
+
+ describe('if collection is selected; but script invoke fails', () => {
+ let scriptRequestSucceeded;
+ beforeEach((done) => {
+ spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
+ jasmine.getEnv().allowRespy(true);
+ spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500));
+ component.navigate(mockCollection).subscribe((succeeded: boolean) => {
+ scriptRequestSucceeded = succeeded;
+ done();
+ });
+ });
+ it('error notification is shown', () => {
+ expect(scriptRequestSucceeded).toBeFalse();
+ expect(notificationService.error).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.ts
new file mode 100644
index 0000000000..0645e09029
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.ts
@@ -0,0 +1,111 @@
+import { Component, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { TranslateService } from '@ngx-translate/core';
+import { Observable, of as observableOf } from 'rxjs';
+import { map, switchMap } from 'rxjs/operators';
+import { BATCH_EXPORT_SCRIPT_NAME, ScriptDataService } from '../../../../core/data/processes/script-data.service';
+import { Collection } from '../../../../core/shared/collection.model';
+import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
+import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
+import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
+import { ProcessParameter } from '../../../../process-page/processes/process-parameter.model';
+import { ConfirmationModalComponent } from '../../../confirmation-modal/confirmation-modal.component';
+import { isNotEmpty } from '../../../empty.util';
+import { NotificationsService } from '../../../notifications/notifications.service';
+import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
+import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component';
+import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
+import { Process } from '../../../../process-page/processes/process.model';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
+import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
+import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
+
+/**
+ * Component to wrap a list of existing dso's inside a modal
+ * Used to choose a dso from to export metadata of
+ */
+@Component({
+ selector: 'ds-export-metadata-selector',
+ templateUrl: '../dso-selector-modal-wrapper.component.html',
+})
+export class ExportBatchSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
+ objectType = DSpaceObjectType.DSPACEOBJECT;
+ selectorTypes = [DSpaceObjectType.COLLECTION];
+ action = SelectorActionType.EXPORT_BATCH;
+
+ constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router,
+ protected notificationsService: NotificationsService, protected translationService: TranslateService,
+ protected scriptDataService: ScriptDataService,
+ protected authorizationDataService: AuthorizationDataService,
+ private modalService: NgbModal) {
+ super(activeModal, route);
+ }
+
+ /**
+ * If the dso is a collection or community: start export-metadata script & navigate to process if successful
+ * Otherwise show error message
+ */
+ navigate(dso: DSpaceObject): Observable {
+ if (dso instanceof Collection) {
+ const modalRef = this.modalService.open(ConfirmationModalComponent);
+ modalRef.componentInstance.dso = dso;
+ modalRef.componentInstance.headerLabel = 'confirmation-modal.export-batch.header';
+ modalRef.componentInstance.infoLabel = 'confirmation-modal.export-batch.info';
+ modalRef.componentInstance.cancelLabel = 'confirmation-modal.export-batch.cancel';
+ modalRef.componentInstance.confirmLabel = 'confirmation-modal.export-batch.confirm';
+ modalRef.componentInstance.confirmIcon = 'fas fa-file-export';
+ const resp$ = modalRef.componentInstance.response.pipe(switchMap((confirm: boolean) => {
+ if (confirm) {
+ const startScriptSucceeded$ = this.startScriptNotifyAndRedirect(dso);
+ return startScriptSucceeded$.pipe(
+ switchMap((r: boolean) => {
+ return observableOf(r);
+ })
+ );
+ } else {
+ const modalRefExport = this.modalService.open(ExportBatchSelectorComponent);
+ modalRefExport.componentInstance.dsoRD = createSuccessfulRemoteDataObject(dso);
+ }
+ }));
+ resp$.subscribe();
+ return resp$;
+ } else {
+ return observableOf(false);
+ }
+ }
+
+ /**
+ * Start export-metadata script of dso & navigate to process if successful
+ * Otherwise show error message
+ * @param dso Dso to export
+ */
+ private startScriptNotifyAndRedirect(dso: DSpaceObject): Observable {
+ const parameterValues: ProcessParameter[] = [
+ Object.assign(new ProcessParameter(), { name: '--id', value: dso.uuid }),
+ Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' })
+ ];
+ return this.authorizationDataService.isAuthorized(FeatureID.AdministratorOf).pipe(
+ switchMap(() => {
+ return this.scriptDataService.invoke(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
+ }),
+ getFirstCompletedRemoteData(),
+ map((rd: RemoteData) => {
+ if (rd.hasSucceeded) {
+ const title = this.translationService.get('process.new.notification.success.title');
+ const content = this.translationService.get('process.new.notification.success.content');
+ this.notificationsService.success(title, content);
+ if (isNotEmpty(rd.payload)) {
+ this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
+ }
+ return true;
+ } else {
+ const title = this.translationService.get('process.new.notification.error.title');
+ const content = this.translationService.get('process.new.notification.error.content');
+ this.notificationsService.error(title, content);
+ return false;
+ }
+ })
+ );
+ }
+}
diff --git a/src/app/shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component.spec.ts
new file mode 100644
index 0000000000..6ed3bf28be
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component.spec.ts
@@ -0,0 +1,77 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { TranslateModule } from '@ngx-translate/core';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { Collection } from '../../../../core/shared/collection.model';
+import { Item } from '../../../../core/shared/item.model';
+import { ImportBatchSelectorComponent } from './import-batch-selector.component';
+
+describe('ImportBatchSelectorComponent', () => {
+ let component: ImportBatchSelectorComponent;
+ let fixture: ComponentFixture;
+ const mockItem = Object.assign(new Item(), {
+ id: 'fake-id',
+ uuid: 'fake-id',
+ handle: 'fake/handle',
+ lastModified: '2018'
+ });
+ const mockCollection: Collection = Object.assign(new Collection(), {
+ id: 'test-collection-1-1',
+ uuid: 'test-collection-1-1',
+ name: 'test-collection-1',
+ metadata: {
+ 'dc.identifier.uri': [
+ {
+ language: null,
+ value: 'fake/test-collection-1'
+ }
+ ]
+ }
+ });
+ const modalStub = jasmine.createSpyObj('modalStub', ['close']);
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ declarations: [ImportBatchSelectorComponent],
+ providers: [
+ { provide: NgbActiveModal, useValue: modalStub },
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ImportBatchSelectorComponent);
+ component = fixture.componentInstance;
+ spyOn(component.response, 'emit');
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('if item is selected', () => {
+ beforeEach((done) => {
+ component.navigate(mockItem).subscribe(() => {
+ done();
+ });
+ });
+ it('should emit null value', () => {
+ expect(component.response.emit).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('if collection is selected', () => {
+ beforeEach((done) => {
+ component.navigate(mockCollection).subscribe(() => {
+ done();
+ });
+ });
+ it('should emit collection value', () => {
+ expect(component.response.emit).toHaveBeenCalledWith(mockCollection);
+ });
+ });
+
+});
diff --git a/src/app/shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component.ts
new file mode 100644
index 0000000000..4696e42e2d
--- /dev/null
+++ b/src/app/shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component.ts
@@ -0,0 +1,44 @@
+import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { Collection } from '../../../../core/shared/collection.model';
+import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
+import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component';
+import { Observable, of } from 'rxjs';
+
+/**
+ * Component to wrap a list of existing dso's inside a modal
+ * Used to choose a dso from to import metadata of
+ */
+@Component({
+ selector: 'ds-import-batch-selector',
+ templateUrl: '../dso-selector-modal-wrapper.component.html',
+})
+export class ImportBatchSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
+ objectType = DSpaceObjectType.DSPACEOBJECT;
+ selectorTypes = [DSpaceObjectType.COLLECTION];
+ action = SelectorActionType.IMPORT_BATCH;
+ /**
+ * An event fired when the modal is closed
+ */
+ @Output()
+ response = new EventEmitter();
+
+ constructor(protected activeModal: NgbActiveModal,
+ protected route: ActivatedRoute) {
+ super(activeModal, route);
+ }
+
+ /**
+ * If the dso is a collection:
+ */
+ navigate(dso: DSpaceObject): Observable {
+ if (dso instanceof Collection) {
+ this.response.emit(dso);
+ return of(null);
+ }
+ this.response.emit(null);
+ return of(null);
+ }
+}
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index 0a82134e95..9f9f937d59 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -24,6 +24,12 @@ import { ConfirmationModalComponent } from './confirmation-modal/confirmation-mo
import {
ExportMetadataSelectorComponent
} from './dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component';
+import {
+ ExportBatchSelectorComponent
+} from './dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component';
+import {
+ ImportBatchSelectorComponent
+} from './dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component';
import { FileDropzoneNoUploaderComponent } from './file-dropzone-no-uploader/file-dropzone-no-uploader.component';
import { ItemListElementComponent } from './object-list/item-list-element/item-types/item/item-list-element.component';
import { EnumKeysPipe } from './utils/enum-keys-pipe';
@@ -476,6 +482,8 @@ const COMPONENTS = [
CollectionDropdownComponent,
EntityDropdownComponent,
ExportMetadataSelectorComponent,
+ ImportBatchSelectorComponent,
+ ExportBatchSelectorComponent,
ConfirmationModalComponent,
VocabularyTreeviewComponent,
AuthorizedCollectionSelectorComponent,
@@ -555,6 +563,8 @@ const ENTRY_COMPONENTS = [
BitstreamRequestACopyPageComponent,
CurationFormComponent,
ExportMetadataSelectorComponent,
+ ImportBatchSelectorComponent,
+ ExportBatchSelectorComponent,
ConfirmationModalComponent,
VocabularyTreeviewComponent,
SidebarSearchListElementComponent,
diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5
index 9d40c0d168..feeeb7eaba 100644
--- a/src/assets/i18n/en.json5
+++ b/src/assets/i18n/en.json5
@@ -542,28 +542,45 @@
"admin.metadata-import.breadcrumbs": "Import Metadata",
+ "admin.batch-import.breadcrumbs": "Import Batch",
+
"admin.metadata-import.title": "Import Metadata",
+ "admin.batch-import.title": "Import Batch",
+
"admin.metadata-import.page.header": "Import Metadata",
+ "admin.batch-import.page.header": "Import Batch",
+
"admin.metadata-import.page.help": "You can drop or browse CSV files that contain batch metadata operations on files here",
+ "admin.batch-import.page.help": "Select the Collection to import into. Then, drop or browse to a Simple Archive Format (SAF) zip file that includes the Items to import",
+
"admin.metadata-import.page.dropMsg": "Drop a metadata CSV to import",
+ "admin.batch-import.page.dropMsg": "Drop a batch ZIP to import",
+
"admin.metadata-import.page.dropMsgReplace": "Drop to replace the metadata CSV to import",
+ "admin.batch-import.page.dropMsgReplace": "Drop to replace the batch ZIP to import",
+
"admin.metadata-import.page.button.return": "Back",
"admin.metadata-import.page.button.proceed": "Proceed",
+ "admin.metadata-import.page.button.select-collection": "Select Collection",
+
"admin.metadata-import.page.error.addFile": "Select file first!",
+ "admin.batch-import.page.error.addFile": "Select Zip file first!",
+
"admin.metadata-import.page.validateOnly": "Validate Only",
"admin.metadata-import.page.validateOnly.hint": "When selected, the uploaded CSV will be validated. You will receive a report of detected changes, but no changes will be saved.",
+ "admin.batch-import.page.validateOnly.hint": "When selected, the uploaded ZIP will be validated. You will receive a report of detected changes, but no changes will be saved.",
-
+ "admin.batch-import.page.remove": "remove",
"auth.errors.invalid-user": "Invalid email address or password.",
@@ -1346,6 +1363,10 @@
"dso-selector.export-metadata.dspaceobject.head": "Export metadata from",
+ "dso-selector.export-batch.dspaceobject.head": "Export Batch (ZIP) from",
+
+ "dso-selector.import-batch.dspaceobject.head": "Import batch from",
+
"dso-selector.no-results": "No {{ type }} found",
"dso-selector.placeholder": "Search for a {{ type }}",
@@ -1374,6 +1395,14 @@
"confirmation-modal.export-metadata.confirm": "Export",
+ "confirmation-modal.export-batch.header": "Export batch (ZIP) for {{ dsoName }}",
+
+ "confirmation-modal.export-batch.info": "Are you sure you want to export batch (ZIP) for {{ dsoName }}",
+
+ "confirmation-modal.export-batch.cancel": "Cancel",
+
+ "confirmation-modal.export-batch.confirm": "Export",
+
"confirmation-modal.delete-eperson.header": "Delete EPerson \"{{ dsoName }}\"",
"confirmation-modal.delete-eperson.info": "Are you sure you want to delete EPerson \"{{ dsoName }}\"",
@@ -2617,6 +2646,7 @@
"menu.section.export_metadata": "Metadata",
+ "menu.section.export_batch": "Batch Export (ZIP)",
"menu.section.icon.access_control": "Access Control menu section",