Skip to content

Commit 0ef8dcc

Browse files
authored
Merge pull request DSpace#1827 from 4Science/CST-6685
Create new batch export/import page to/from ZIP
2 parents e1b21e2 + 0025567 commit 0ef8dcc

15 files changed

Lines changed: 844 additions & 17 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<div class="container">
2+
<h2 id="header">{{'admin.batch-import.page.header' | translate}}</h2>
3+
<p>{{'admin.batch-import.page.help' | translate}}</p>
4+
<p *ngIf="dso">
5+
selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp;
6+
<a href="javascript:void(0)" (click)="removeDspaceObject()">{{'admin.batch-import.page.remove' | translate}}</a>
7+
</p>
8+
<p>
9+
<button class="btn btn-primary" (click)="this.selectCollection();">{{'admin.metadata-import.page.button.select-collection' | translate}}</button>
10+
</p>
11+
<div class="form-group">
12+
<div class="form-check">
13+
<input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly">
14+
<label class="form-check-label" for="validateOnly">
15+
{{'admin.metadata-import.page.validateOnly' | translate}}
16+
</label>
17+
</div>
18+
<small id="validateOnlyHelpBlock" class="form-text text-muted">
19+
{{'admin.batch-import.page.validateOnly.hint' | translate}}
20+
</small>
21+
</div>
22+
23+
<ds-file-dropzone-no-uploader
24+
(onFileAdded)="setFile($event)"
25+
[dropMessageLabel]="'admin.batch-import.page.dropMsg'"
26+
[dropMessageLabelReplacement]="'admin.batch-import.page.dropMsgReplace'">
27+
</ds-file-dropzone-no-uploader>
28+
29+
<div class="space-children-mr">
30+
<button class="btn btn-secondary" id="backButton"
31+
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>
32+
<button class="btn btn-primary" id="proceedButton"
33+
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button>
34+
</div>
35+
</div>
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
2+
import { BatchImportPageComponent } from './batch-import-page.component';
3+
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
4+
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
5+
import { FormsModule } from '@angular/forms';
6+
import { TranslateModule } from '@ngx-translate/core';
7+
import { RouterTestingModule } from '@angular/router/testing';
8+
import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive';
9+
import { FileValidator } from '../../shared/utils/require-file.validator';
10+
import { NotificationsService } from '../../shared/notifications/notifications.service';
11+
import {
12+
BATCH_IMPORT_SCRIPT_NAME,
13+
ScriptDataService
14+
} from '../../core/data/processes/script-data.service';
15+
import { Router } from '@angular/router';
16+
import { Location } from '@angular/common';
17+
import { NO_ERRORS_SCHEMA } from '@angular/core';
18+
import { By } from '@angular/platform-browser';
19+
import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
20+
21+
describe('BatchImportPageComponent', () => {
22+
let component: BatchImportPageComponent;
23+
let fixture: ComponentFixture<BatchImportPageComponent>;
24+
25+
let notificationService: NotificationsServiceStub;
26+
let scriptService: any;
27+
let router;
28+
let locationStub;
29+
30+
function init() {
31+
notificationService = new NotificationsServiceStub();
32+
scriptService = jasmine.createSpyObj('scriptService',
33+
{
34+
invoke: createSuccessfulRemoteDataObject$({ processId: '46' })
35+
}
36+
);
37+
router = jasmine.createSpyObj('router', {
38+
navigateByUrl: jasmine.createSpy('navigateByUrl')
39+
});
40+
locationStub = jasmine.createSpyObj('location', {
41+
back: jasmine.createSpy('back')
42+
});
43+
}
44+
45+
beforeEach(waitForAsync(() => {
46+
init();
47+
TestBed.configureTestingModule({
48+
imports: [
49+
FormsModule,
50+
TranslateModule.forRoot(),
51+
RouterTestingModule.withRoutes([])
52+
],
53+
declarations: [BatchImportPageComponent, FileValueAccessorDirective, FileValidator],
54+
providers: [
55+
{ provide: NotificationsService, useValue: notificationService },
56+
{ provide: ScriptDataService, useValue: scriptService },
57+
{ provide: Router, useValue: router },
58+
{ provide: Location, useValue: locationStub },
59+
],
60+
schemas: [NO_ERRORS_SCHEMA]
61+
}).compileComponents();
62+
}));
63+
64+
beforeEach(() => {
65+
fixture = TestBed.createComponent(BatchImportPageComponent);
66+
component = fixture.componentInstance;
67+
fixture.detectChanges();
68+
});
69+
70+
it('should create', () => {
71+
expect(component).toBeTruthy();
72+
});
73+
74+
describe('if back button is pressed', () => {
75+
beforeEach(fakeAsync(() => {
76+
const proceed = fixture.debugElement.query(By.css('#backButton')).nativeElement;
77+
proceed.click();
78+
fixture.detectChanges();
79+
}));
80+
it('should do location.back', () => {
81+
expect(locationStub.back).toHaveBeenCalled();
82+
});
83+
});
84+
85+
describe('if file is set', () => {
86+
let fileMock: File;
87+
88+
beforeEach(() => {
89+
fileMock = new File([''], 'filename.zip', { type: 'application/zip' });
90+
component.setFile(fileMock);
91+
});
92+
93+
describe('if proceed button is pressed without validate only', () => {
94+
beforeEach(fakeAsync(() => {
95+
component.validateOnly = false;
96+
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
97+
proceed.click();
98+
fixture.detectChanges();
99+
}));
100+
it('metadata-import script is invoked with --zip fileName and the mockFile', () => {
101+
const parameterValues: ProcessParameter[] = [
102+
Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }),
103+
];
104+
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--add' }));
105+
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]);
106+
});
107+
it('success notification is shown', () => {
108+
expect(notificationService.success).toHaveBeenCalled();
109+
});
110+
it('redirected to process page', () => {
111+
expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46');
112+
});
113+
});
114+
115+
describe('if proceed button is pressed with validate only', () => {
116+
beforeEach(fakeAsync(() => {
117+
component.validateOnly = true;
118+
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
119+
proceed.click();
120+
fixture.detectChanges();
121+
}));
122+
it('metadata-import script is invoked with --zip fileName and the mockFile and -v validate-only', () => {
123+
const parameterValues: ProcessParameter[] = [
124+
Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }),
125+
Object.assign(new ProcessParameter(), { name: '--add' }),
126+
Object.assign(new ProcessParameter(), { name: '-v', value: true }),
127+
];
128+
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]);
129+
});
130+
it('success notification is shown', () => {
131+
expect(notificationService.success).toHaveBeenCalled();
132+
});
133+
it('redirected to process page', () => {
134+
expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46');
135+
});
136+
});
137+
138+
describe('if proceed is pressed; but script invoke fails', () => {
139+
beforeEach(fakeAsync(() => {
140+
jasmine.getEnv().allowRespy(true);
141+
spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500));
142+
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
143+
proceed.click();
144+
fixture.detectChanges();
145+
}));
146+
it('error notification is shown', () => {
147+
expect(notificationService.error).toHaveBeenCalled();
148+
});
149+
});
150+
});
151+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Component } from '@angular/core';
2+
import { Location } from '@angular/common';
3+
import { TranslateService } from '@ngx-translate/core';
4+
import { NotificationsService } from '../../shared/notifications/notifications.service';
5+
import { BATCH_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service';
6+
import { Router } from '@angular/router';
7+
import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
8+
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
9+
import { RemoteData } from '../../core/data/remote-data';
10+
import { Process } from '../../process-page/processes/process.model';
11+
import { isNotEmpty } from '../../shared/empty.util';
12+
import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths';
13+
import {
14+
ImportBatchSelectorComponent
15+
} from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component';
16+
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
17+
import { take } from 'rxjs/operators';
18+
import { DSpaceObject } from '../../core/shared/dspace-object.model';
19+
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
20+
21+
@Component({
22+
selector: 'ds-batch-import-page',
23+
templateUrl: './batch-import-page.component.html'
24+
})
25+
export class BatchImportPageComponent {
26+
/**
27+
* The current value of the file
28+
*/
29+
fileObject: File;
30+
31+
/**
32+
* The validate only flag
33+
*/
34+
validateOnly = true;
35+
/**
36+
* dso object for community or collection
37+
*/
38+
dso: DSpaceObject = null;
39+
40+
public constructor(private location: Location,
41+
protected translate: TranslateService,
42+
protected notificationsService: NotificationsService,
43+
private scriptDataService: ScriptDataService,
44+
private router: Router,
45+
private modalService: NgbModal,
46+
private dsoNameService: DSONameService) {
47+
}
48+
49+
/**
50+
* Set file
51+
* @param file
52+
*/
53+
setFile(file) {
54+
this.fileObject = file;
55+
}
56+
57+
/**
58+
* When return button is pressed go to previous location
59+
*/
60+
public onReturn() {
61+
this.location.back();
62+
}
63+
64+
public selectCollection() {
65+
const modalRef = this.modalService.open(ImportBatchSelectorComponent);
66+
modalRef.componentInstance.response.pipe(take(1)).subscribe((dso) => {
67+
this.dso = dso || null;
68+
});
69+
}
70+
71+
/**
72+
* Starts import-metadata script with --zip fileName (and the selected file)
73+
*/
74+
public importMetadata() {
75+
if (this.fileObject == null) {
76+
this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile'));
77+
} else {
78+
const parameterValues: ProcessParameter[] = [
79+
Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }),
80+
Object.assign(new ProcessParameter(), { name: '--add' })
81+
];
82+
if (this.dso) {
83+
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid }));
84+
}
85+
if (this.validateOnly) {
86+
parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true }));
87+
}
88+
89+
this.scriptDataService.invoke(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe(
90+
getFirstCompletedRemoteData(),
91+
).subscribe((rd: RemoteData<Process>) => {
92+
if (rd.hasSucceeded) {
93+
const title = this.translate.get('process.new.notification.success.title');
94+
const content = this.translate.get('process.new.notification.success.content');
95+
this.notificationsService.success(title, content);
96+
if (isNotEmpty(rd.payload)) {
97+
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
98+
}
99+
} else {
100+
const title = this.translate.get('process.new.notification.error.title');
101+
const content = this.translate.get('process.new.notification.error.content');
102+
this.notificationsService.error(title, content);
103+
}
104+
});
105+
}
106+
}
107+
108+
/**
109+
* return selected dspace object name
110+
*/
111+
getDspaceObjectName(): string {
112+
if (this.dso) {
113+
return this.dsoNameService.getName(this.dso);
114+
}
115+
return null;
116+
}
117+
118+
/**
119+
* remove selected dso object
120+
*/
121+
removeDspaceObject(): void {
122+
this.dso = null;
123+
}
124+
}

src/app/admin/admin-routing.module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow
77
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
88
import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component';
99
import { REGISTRIES_MODULE_PATH } from './admin-routing-paths';
10+
import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
1011

1112
@NgModule({
1213
imports: [
@@ -40,6 +41,12 @@ import { REGISTRIES_MODULE_PATH } from './admin-routing-paths';
4041
component: MetadataImportPageComponent,
4142
data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' }
4243
},
44+
{
45+
path: 'batch-import',
46+
resolve: { breadcrumb: I18nBreadcrumbResolver },
47+
component: BatchImportPageComponent,
48+
data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }
49+
},
4350
])
4451
],
4552
providers: [

src/app/admin/admin.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { AdminWorkflowModuleModule } from './admin-workflow-page/admin-workflow.
99
import { AdminSearchModule } from './admin-search-page/admin-search.module';
1010
import { AdminSidebarSectionComponent } from './admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
1111
import { ExpandableAdminSidebarSectionComponent } from './admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
12+
import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
1213

1314
const ENTRY_COMPONENTS = [
1415
// put only entry components that use custom decorator
@@ -28,7 +29,8 @@ const ENTRY_COMPONENTS = [
2829
],
2930
declarations: [
3031
AdminCurationTasksComponent,
31-
MetadataImportPageComponent
32+
MetadataImportPageComponent,
33+
BatchImportPageComponent
3234
]
3335
})
3436
export class AdminModule {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { dataService } from '../base/data-service.decorator';
2424

2525
export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import';
2626
export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export';
27+
export const BATCH_IMPORT_SCRIPT_NAME = 'import';
28+
export const BATCH_EXPORT_SCRIPT_NAME = 'export';
2729

2830
@Injectable()
2931
@dataService(SCRIPT)

src/app/menu.resolver.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,15 @@ describe('MenuResolver', () => {
259259
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
260260
id: 'import', visible: true,
261261
}));
262+
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
263+
id: 'import_batch', parentID: 'import', visible: true,
264+
}));
262265
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
263266
id: 'export', visible: true,
264267
}));
268+
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
269+
id: 'export_batch', parentID: 'export', visible: true,
270+
}));
265271
});
266272
});
267273

0 commit comments

Comments
 (0)