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 index 03bbbe8af9..7171c67585 100644 --- 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 @@ -77,8 +77,8 @@ export class BatchImportPageComponent { } else { const parameterValues: ProcessParameter[] = [ Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }), + Object.assign(new ProcessParameter(), { name: '--add' }) ]; - parameterValues.push(Object.assign(new ProcessParameter(), { name: '--add' })); if (this.dso) { parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid })); } diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index 58674d4005..d9c92cb1d2 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -25,6 +25,7 @@ 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 f39075ad27..4fd44efe66 100644 --- a/src/app/menu.resolver.spec.ts +++ b/src/app/menu.resolver.spec.ts @@ -265,6 +265,9 @@ describe('MenuResolver', () => { 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 a7c1fe415e..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 + }); }); } 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 937608a2c6..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 @@ -11,7 +11,8 @@ export enum SelectorActionType { EDIT = 'edit', EXPORT_METADATA = 'export-metadata', IMPORT_BATCH = 'import-batch', - SET_SCOPE = 'set-scope' + 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..bfd10346b3 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.spec.ts @@ -0,0 +1,211 @@ +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 and -a option', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--id', value: mockCollection.uuid }), + Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' }), + Object.assign(new ProcessParameter(), { name: '-a' }), + ]; + 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 the -a 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..cb09b34c58 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.ts @@ -0,0 +1,114 @@ +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((isAdmin) => { + if (isAdmin) { + parameterValues.push(Object.assign(new ProcessParameter(), {name: '-a'})); + } + 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 index 576c1ea140..6ed3bf28be 100644 --- 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 @@ -4,7 +4,6 @@ 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 { Community } from '../../../../core/shared/community.model'; import { Item } from '../../../../core/shared/item.model'; import { ImportBatchSelectorComponent } from './import-batch-selector.component'; @@ -30,18 +29,6 @@ describe('ImportBatchSelectorComponent', () => { ] } }); - const mockCommunity = Object.assign(new Community(), { - id: 'test-uuid', - uuid: 'test-uuid', - metadata: { - 'dc.identifier.uri': [ - { - language: null, - value: 'fake/test-community-1' - } - ] - } - }); const modalStub = jasmine.createSpyObj('modalStub', ['close']); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -87,14 +74,4 @@ describe('ImportBatchSelectorComponent', () => { }); }); - describe('if community is selected', () => { - beforeEach((done) => { - component.navigate(mockCommunity).subscribe(() => { - done(); - }); - }); - it('should emit community value', () => { - expect(component.response.emit).toHaveBeenCalledWith(mockCommunity); - }); - }); }); 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 index a88aeaff3c..4696e42e2d 100644 --- 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 @@ -1,7 +1,6 @@ import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Collection } from '../../../../core/shared/collection.model'; -import { Community } from '../../../../core/shared/community.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'; @@ -32,10 +31,10 @@ export class ImportBatchSelectorComponent extends DSOSelectorModalWrapperCompone } /** - * If the dso is a collection or community: + * If the dso is a collection: */ navigate(dso: DSpaceObject): Observable { - if (dso instanceof Collection || dso instanceof Community) { + if (dso instanceof Collection) { this.response.emit(dso); return of(null); } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index fc65fb6882..9d4ca55e51 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -24,6 +24,9 @@ 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'; @@ -475,6 +478,7 @@ const COMPONENTS = [ EntityDropdownComponent, ExportMetadataSelectorComponent, ImportBatchSelectorComponent, + ExportBatchSelectorComponent, ConfirmationModalComponent, VocabularyTreeviewComponent, AuthorizedCollectionSelectorComponent, @@ -554,6 +558,7 @@ const ENTRY_COMPONENTS = [ CurationFormComponent, ExportMetadataSelectorComponent, ImportBatchSelectorComponent, + ExportBatchSelectorComponent, ConfirmationModalComponent, VocabularyTreeviewComponent, SidebarSearchListElementComponent, diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 93ab5302fb..10565b4d39 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1363,6 +1363,8 @@ "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", @@ -1393,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 }}\"", @@ -2636,6 +2646,7 @@ "menu.section.export_metadata": "Metadata", + "menu.section.export_batch": "Batch Export(ZIP)", "menu.section.icon.access_control": "Access Control menu section",