Skip to content

Commit 98ee075

Browse files
committed
[CST-6685] added new batch export functionality
1 parent b89640e commit 98ee075

11 files changed

Lines changed: 367 additions & 28 deletions

File tree

src/app/admin/admin-import-batch-page/batch-import-page.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ export class BatchImportPageComponent {
7777
} else {
7878
const parameterValues: ProcessParameter[] = [
7979
Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }),
80+
Object.assign(new ProcessParameter(), { name: '--add' })
8081
];
81-
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--add' }));
8282
if (this.dso) {
8383
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid }));
8484
}

src/app/core/data/processes/script-data.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { dataService } from '../base/data-service.decorator';
2525
export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import';
2626
export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export';
2727
export const BATCH_IMPORT_SCRIPT_NAME = 'import';
28+
export const BATCH_EXPORT_SCRIPT_NAME = 'export';
2829

2930
@Injectable()
3031
@dataService(SCRIPT)

src/app/menu.resolver.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,9 @@ describe('MenuResolver', () => {
265265
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
266266
id: 'export', visible: true,
267267
}));
268+
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
269+
id: 'export_batch', parentID: 'export', visible: true,
270+
}));
268271
});
269272
});
270273

src/app/menu.resolver.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ import {
4444
METADATA_IMPORT_SCRIPT_NAME,
4545
ScriptDataService
4646
} from './core/data/processes/script-data.service';
47+
import {
48+
ExportBatchSelectorComponent
49+
} from './shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component';
4750

4851
/**
4952
* Creates all of the app's menus
@@ -440,6 +443,20 @@ export class MenuResolver implements Resolve<boolean> {
440443
} as OnClickMenuItemModel,
441444
shouldPersistOnRouteChange: true
442445
});
446+
this.menuService.addSection(MenuID.ADMIN, {
447+
id: 'export_batch',
448+
parentID: 'export',
449+
active: false,
450+
visible: true,
451+
model: {
452+
type: MenuItemType.ONCLICK,
453+
text: 'menu.section.export_batch',
454+
function: () => {
455+
this.modalService.open(ExportBatchSelectorComponent);
456+
}
457+
} as OnClickMenuItemModel,
458+
shouldPersistOnRouteChange: true
459+
});
443460
});
444461
}
445462

src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export enum SelectorActionType {
1111
EDIT = 'edit',
1212
EXPORT_METADATA = 'export-metadata',
1313
IMPORT_BATCH = 'import-batch',
14-
SET_SCOPE = 'set-scope'
14+
SET_SCOPE = 'set-scope',
15+
EXPORT_BATCH = 'export-batch'
1516
}
1617

1718
/**
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { of as observableOf } from 'rxjs';
2+
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
3+
import { RouterTestingModule } from '@angular/router/testing';
4+
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
5+
import { DebugElement, NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
6+
import { NgbActiveModal, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
7+
import { ActivatedRoute, Router } from '@angular/router';
8+
import { BATCH_EXPORT_SCRIPT_NAME, ScriptDataService } from '../../../../core/data/processes/script-data.service';
9+
import { Collection } from '../../../../core/shared/collection.model';
10+
import { Item } from '../../../../core/shared/item.model';
11+
import { ProcessParameter } from '../../../../process-page/processes/process-parameter.model';
12+
import { ConfirmationModalComponent } from '../../../confirmation-modal/confirmation-modal.component';
13+
import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock';
14+
import { NotificationsService } from '../../../notifications/notifications.service';
15+
import { NotificationsServiceStub } from '../../../testing/notifications-service.stub';
16+
import {
17+
createFailedRemoteDataObject$,
18+
createSuccessfulRemoteDataObject,
19+
createSuccessfulRemoteDataObject$
20+
} from '../../../remote-data.utils';
21+
import { ExportBatchSelectorComponent } from './export-batch-selector.component';
22+
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
23+
24+
// 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
25+
@NgModule({
26+
imports: [NgbModalModule,
27+
TranslateModule.forRoot({
28+
loader: {
29+
provide: TranslateLoader,
30+
useClass: TranslateLoaderMock
31+
}
32+
}),
33+
],
34+
exports: [],
35+
declarations: [ConfirmationModalComponent],
36+
providers: []
37+
})
38+
class ModelTestModule {
39+
}
40+
41+
describe('ExportBatchSelectorComponent', () => {
42+
let component: ExportBatchSelectorComponent;
43+
let fixture: ComponentFixture<ExportBatchSelectorComponent>;
44+
let debugElement: DebugElement;
45+
let modalRef;
46+
47+
let router;
48+
let notificationService: NotificationsServiceStub;
49+
let scriptService;
50+
let authorizationDataService;
51+
52+
const mockItem = Object.assign(new Item(), {
53+
id: 'fake-id',
54+
uuid: 'fake-id',
55+
handle: 'fake/handle',
56+
lastModified: '2018'
57+
});
58+
59+
const mockCollection: Collection = Object.assign(new Collection(), {
60+
id: 'test-collection-1-1',
61+
uuid: 'test-collection-1-1',
62+
name: 'test-collection-1',
63+
metadata: {
64+
'dc.identifier.uri': [
65+
{
66+
language: null,
67+
value: 'fake/test-collection-1'
68+
}
69+
]
70+
}
71+
});
72+
const itemRD = createSuccessfulRemoteDataObject(mockItem);
73+
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
74+
75+
beforeEach(waitForAsync(() => {
76+
notificationService = new NotificationsServiceStub();
77+
router = jasmine.createSpyObj('router', {
78+
navigateByUrl: jasmine.createSpy('navigateByUrl')
79+
});
80+
scriptService = jasmine.createSpyObj('scriptService',
81+
{
82+
invoke: createSuccessfulRemoteDataObject$({ processId: '45' })
83+
}
84+
);
85+
authorizationDataService = jasmine.createSpyObj('authorizationDataService', {
86+
isAuthorized: observableOf(true)
87+
});
88+
TestBed.configureTestingModule({
89+
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), ModelTestModule],
90+
declarations: [ExportBatchSelectorComponent],
91+
providers: [
92+
{ provide: NgbActiveModal, useValue: modalStub },
93+
{ provide: NotificationsService, useValue: notificationService },
94+
{ provide: ScriptDataService, useValue: scriptService },
95+
{ provide: AuthorizationDataService, useValue: authorizationDataService },
96+
{
97+
provide: ActivatedRoute,
98+
useValue: {
99+
root: {
100+
snapshot: {
101+
data: {
102+
dso: itemRD,
103+
},
104+
},
105+
}
106+
},
107+
},
108+
{
109+
provide: Router, useValue: router
110+
}
111+
],
112+
schemas: [NO_ERRORS_SCHEMA]
113+
}).compileComponents();
114+
115+
}));
116+
117+
beforeEach(() => {
118+
fixture = TestBed.createComponent(ExportBatchSelectorComponent);
119+
component = fixture.componentInstance;
120+
debugElement = fixture.debugElement;
121+
const modalService = TestBed.inject(NgbModal);
122+
modalRef = modalService.open(ConfirmationModalComponent);
123+
modalRef.componentInstance.response = observableOf(true);
124+
fixture.detectChanges();
125+
});
126+
127+
it('should create', () => {
128+
expect(component).toBeTruthy();
129+
});
130+
131+
describe('if item is selected', () => {
132+
let scriptRequestSucceeded;
133+
beforeEach((done) => {
134+
component.navigate(mockItem).subscribe((succeeded: boolean) => {
135+
scriptRequestSucceeded = succeeded;
136+
done();
137+
});
138+
});
139+
it('should not invoke batch-export script', () => {
140+
expect(scriptService.invoke).not.toHaveBeenCalled();
141+
});
142+
});
143+
144+
describe('if collection is selected and is admin', () => {
145+
let scriptRequestSucceeded;
146+
beforeEach((done) => {
147+
spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
148+
component.navigate(mockCollection).subscribe((succeeded: boolean) => {
149+
scriptRequestSucceeded = succeeded;
150+
done();
151+
});
152+
});
153+
it('should invoke the batch-export script with option --id uuid and -a option', () => {
154+
const parameterValues: ProcessParameter[] = [
155+
Object.assign(new ProcessParameter(), { name: '--id', value: mockCollection.uuid }),
156+
Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' }),
157+
Object.assign(new ProcessParameter(), { name: '-a' }),
158+
];
159+
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
160+
});
161+
it('success notification is shown', () => {
162+
expect(scriptRequestSucceeded).toBeTrue();
163+
expect(notificationService.success).toHaveBeenCalled();
164+
});
165+
it('redirected to process page', () => {
166+
expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45');
167+
});
168+
});
169+
describe('if collection is selected and is not admin', () => {
170+
let scriptRequestSucceeded;
171+
beforeEach((done) => {
172+
(authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
173+
spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
174+
component.navigate(mockCollection).subscribe((succeeded: boolean) => {
175+
scriptRequestSucceeded = succeeded;
176+
done();
177+
});
178+
});
179+
it('should invoke the Batch-export script with option --id uuid without the -a option', () => {
180+
const parameterValues: ProcessParameter[] = [
181+
Object.assign(new ProcessParameter(), { name: '--id', value: mockCollection.uuid }),
182+
Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' })
183+
];
184+
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
185+
});
186+
it('success notification is shown', () => {
187+
expect(scriptRequestSucceeded).toBeTrue();
188+
expect(notificationService.success).toHaveBeenCalled();
189+
});
190+
it('redirected to process page', () => {
191+
expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45');
192+
});
193+
});
194+
195+
describe('if collection is selected; but script invoke fails', () => {
196+
let scriptRequestSucceeded;
197+
beforeEach((done) => {
198+
spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
199+
jasmine.getEnv().allowRespy(true);
200+
spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500));
201+
component.navigate(mockCollection).subscribe((succeeded: boolean) => {
202+
scriptRequestSucceeded = succeeded;
203+
done();
204+
});
205+
});
206+
it('error notification is shown', () => {
207+
expect(scriptRequestSucceeded).toBeFalse();
208+
expect(notificationService.error).toHaveBeenCalled();
209+
});
210+
});
211+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Component, OnInit } from '@angular/core';
2+
import { ActivatedRoute, Router } from '@angular/router';
3+
import { TranslateService } from '@ngx-translate/core';
4+
import { Observable, of as observableOf } from 'rxjs';
5+
import { map, switchMap } from 'rxjs/operators';
6+
import { BATCH_EXPORT_SCRIPT_NAME, ScriptDataService } from '../../../../core/data/processes/script-data.service';
7+
import { Collection } from '../../../../core/shared/collection.model';
8+
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
9+
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
10+
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
11+
import { ProcessParameter } from '../../../../process-page/processes/process-parameter.model';
12+
import { ConfirmationModalComponent } from '../../../confirmation-modal/confirmation-modal.component';
13+
import { isNotEmpty } from '../../../empty.util';
14+
import { NotificationsService } from '../../../notifications/notifications.service';
15+
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
16+
import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component';
17+
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
18+
import { Process } from '../../../../process-page/processes/process.model';
19+
import { RemoteData } from '../../../../core/data/remote-data';
20+
import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
21+
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
22+
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
23+
24+
/**
25+
* Component to wrap a list of existing dso's inside a modal
26+
* Used to choose a dso from to export metadata of
27+
*/
28+
@Component({
29+
selector: 'ds-export-metadata-selector',
30+
templateUrl: '../dso-selector-modal-wrapper.component.html',
31+
})
32+
export class ExportBatchSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
33+
objectType = DSpaceObjectType.DSPACEOBJECT;
34+
selectorTypes = [DSpaceObjectType.COLLECTION];
35+
action = SelectorActionType.EXPORT_BATCH;
36+
37+
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router,
38+
protected notificationsService: NotificationsService, protected translationService: TranslateService,
39+
protected scriptDataService: ScriptDataService,
40+
protected authorizationDataService: AuthorizationDataService,
41+
private modalService: NgbModal) {
42+
super(activeModal, route);
43+
}
44+
45+
/**
46+
* If the dso is a collection or community: start export-metadata script & navigate to process if successful
47+
* Otherwise show error message
48+
*/
49+
navigate(dso: DSpaceObject): Observable<boolean> {
50+
if (dso instanceof Collection) {
51+
const modalRef = this.modalService.open(ConfirmationModalComponent);
52+
modalRef.componentInstance.dso = dso;
53+
modalRef.componentInstance.headerLabel = 'confirmation-modal.export-batch.header';
54+
modalRef.componentInstance.infoLabel = 'confirmation-modal.export-batch.info';
55+
modalRef.componentInstance.cancelLabel = 'confirmation-modal.export-batch.cancel';
56+
modalRef.componentInstance.confirmLabel = 'confirmation-modal.export-batch.confirm';
57+
modalRef.componentInstance.confirmIcon = 'fas fa-file-export';
58+
const resp$ = modalRef.componentInstance.response.pipe(switchMap((confirm: boolean) => {
59+
if (confirm) {
60+
const startScriptSucceeded$ = this.startScriptNotifyAndRedirect(dso);
61+
return startScriptSucceeded$.pipe(
62+
switchMap((r: boolean) => {
63+
return observableOf(r);
64+
})
65+
);
66+
} else {
67+
const modalRefExport = this.modalService.open(ExportBatchSelectorComponent);
68+
modalRefExport.componentInstance.dsoRD = createSuccessfulRemoteDataObject(dso);
69+
}
70+
}));
71+
resp$.subscribe();
72+
return resp$;
73+
} else {
74+
return observableOf(false);
75+
}
76+
}
77+
78+
/**
79+
* Start export-metadata script of dso & navigate to process if successful
80+
* Otherwise show error message
81+
* @param dso Dso to export
82+
*/
83+
private startScriptNotifyAndRedirect(dso: DSpaceObject): Observable<boolean> {
84+
const parameterValues: ProcessParameter[] = [
85+
Object.assign(new ProcessParameter(), { name: '--id', value: dso.uuid }),
86+
Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' })
87+
];
88+
return this.authorizationDataService.isAuthorized(FeatureID.AdministratorOf).pipe(
89+
switchMap((isAdmin) => {
90+
if (isAdmin) {
91+
parameterValues.push(Object.assign(new ProcessParameter(), {name: '-a'}));
92+
}
93+
return this.scriptDataService.invoke(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
94+
}),
95+
getFirstCompletedRemoteData(),
96+
map((rd: RemoteData<Process>) => {
97+
if (rd.hasSucceeded) {
98+
const title = this.translationService.get('process.new.notification.success.title');
99+
const content = this.translationService.get('process.new.notification.success.content');
100+
this.notificationsService.success(title, content);
101+
if (isNotEmpty(rd.payload)) {
102+
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
103+
}
104+
return true;
105+
} else {
106+
const title = this.translationService.get('process.new.notification.error.title');
107+
const content = this.translationService.get('process.new.notification.error.content');
108+
this.notificationsService.error(title, content);
109+
return false;
110+
}
111+
})
112+
);
113+
}
114+
}

0 commit comments

Comments
 (0)