[CST-6685] added new batch export functionality

This commit is contained in:
Nikunj Sharma
2022-09-27 19:35:37 +05:30
parent b89640e4d2
commit 98ee0751ec
11 changed files with 367 additions and 28 deletions

View File

@@ -77,8 +77,8 @@ export class BatchImportPageComponent {
} else { } else {
const parameterValues: ProcessParameter[] = [ const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }), 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) { if (this.dso) {
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid })); parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid }));
} }

View File

@@ -25,6 +25,7 @@ import { dataService } from '../base/data-service.decorator';
export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import';
export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export';
export const BATCH_IMPORT_SCRIPT_NAME = 'import'; export const BATCH_IMPORT_SCRIPT_NAME = 'import';
export const BATCH_EXPORT_SCRIPT_NAME = 'export';
@Injectable() @Injectable()
@dataService(SCRIPT) @dataService(SCRIPT)

View File

@@ -265,6 +265,9 @@ describe('MenuResolver', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export', visible: true, id: 'export', visible: true,
})); }));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export_batch', parentID: 'export', visible: true,
}));
}); });
}); });

View File

@@ -44,6 +44,9 @@ import {
METADATA_IMPORT_SCRIPT_NAME, METADATA_IMPORT_SCRIPT_NAME,
ScriptDataService ScriptDataService
} from './core/data/processes/script-data.service'; } 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 * Creates all of the app's menus
@@ -440,6 +443,20 @@ export class MenuResolver implements Resolve<boolean> {
} as OnClickMenuItemModel, } as OnClickMenuItemModel,
shouldPersistOnRouteChange: true 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
});
}); });
} }

View File

@@ -11,7 +11,8 @@ export enum SelectorActionType {
EDIT = 'edit', EDIT = 'edit',
EXPORT_METADATA = 'export-metadata', EXPORT_METADATA = 'export-metadata',
IMPORT_BATCH = 'import-batch', IMPORT_BATCH = 'import-batch',
SET_SCOPE = 'set-scope' SET_SCOPE = 'set-scope',
EXPORT_BATCH = 'export-batch'
} }
/** /**

View File

@@ -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<ExportBatchSelectorComponent>;
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();
});
});
});

View File

@@ -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<boolean> {
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<boolean> {
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<Process>) => {
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;
}
})
);
}
}

View File

@@ -4,7 +4,6 @@ import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Collection } from '../../../../core/shared/collection.model'; import { Collection } from '../../../../core/shared/collection.model';
import { Community } from '../../../../core/shared/community.model';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { ImportBatchSelectorComponent } from './import-batch-selector.component'; 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']); const modalStub = jasmine.createSpyObj('modalStub', ['close']);
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ 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);
});
});
}); });

View File

@@ -1,7 +1,6 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Collection } from '../../../../core/shared/collection.model'; import { Collection } from '../../../../core/shared/collection.model';
import { Community } from '../../../../core/shared/community.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; 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<null> { navigate(dso: DSpaceObject): Observable<null> {
if (dso instanceof Collection || dso instanceof Community) { if (dso instanceof Collection) {
this.response.emit(dso); this.response.emit(dso);
return of(null); return of(null);
} }

View File

@@ -24,6 +24,9 @@ import { ConfirmationModalComponent } from './confirmation-modal/confirmation-mo
import { import {
ExportMetadataSelectorComponent ExportMetadataSelectorComponent
} from './dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; } 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 { import {
ImportBatchSelectorComponent ImportBatchSelectorComponent
} from './dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component'; } from './dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component';
@@ -475,6 +478,7 @@ const COMPONENTS = [
EntityDropdownComponent, EntityDropdownComponent,
ExportMetadataSelectorComponent, ExportMetadataSelectorComponent,
ImportBatchSelectorComponent, ImportBatchSelectorComponent,
ExportBatchSelectorComponent,
ConfirmationModalComponent, ConfirmationModalComponent,
VocabularyTreeviewComponent, VocabularyTreeviewComponent,
AuthorizedCollectionSelectorComponent, AuthorizedCollectionSelectorComponent,
@@ -554,6 +558,7 @@ const ENTRY_COMPONENTS = [
CurationFormComponent, CurationFormComponent,
ExportMetadataSelectorComponent, ExportMetadataSelectorComponent,
ImportBatchSelectorComponent, ImportBatchSelectorComponent,
ExportBatchSelectorComponent,
ConfirmationModalComponent, ConfirmationModalComponent,
VocabularyTreeviewComponent, VocabularyTreeviewComponent,
SidebarSearchListElementComponent, SidebarSearchListElementComponent,

View File

@@ -1363,6 +1363,8 @@
"dso-selector.export-metadata.dspaceobject.head": "Export metadata from", "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.import-batch.dspaceobject.head": "Import batch from",
"dso-selector.no-results": "No {{ type }} found", "dso-selector.no-results": "No {{ type }} found",
@@ -1393,6 +1395,14 @@
"confirmation-modal.export-metadata.confirm": "Export", "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.header": "Delete EPerson \"{{ dsoName }}\"",
"confirmation-modal.delete-eperson.info": "Are you sure you want to 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_metadata": "Metadata",
"menu.section.export_batch": "Batch Export(ZIP)",
"menu.section.icon.access_control": "Access Control menu section", "menu.section.icon.access_control": "Access Control menu section",