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 index 38c6a1f349f..3f56adfea39 100644 --- 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 @@ -1,57 +1,100 @@

{{'admin.batch-import.page.header' | translate}}

-

{{'admin.batch-import.page.help' | translate}}

- @if (dso) { -

- selected collection: {{getDspaceObjectName()}}  - {{'admin.batch-import.page.remove' | translate}} -

- } -

- -

-
-
- - -
- - {{'admin.batch-import.page.validateOnly.hint' | translate}} - -
+ @if (stagedUploads.progress$ | async; as progress) { + @if (stagedUploads.retained?.file) { +

{{ 'admin.batch-import.upload.file' | translate: { name: stagedUploads.retained?.file.name } }}

+

+ @if (collectionName) { + {{ 'admin.batch-import.upload.collection' | translate: { collection: collectionName } }} + } @else { + {{ 'admin.batch-import.upload.collection.none' | translate }} + } +

+ @if (!uploading) { +

{{ 'admin.batch-import.upload.resume' | translate }}

+ } + } @else { +

{{'admin.batch-import.page.help' | translate}}

+
+ @if (dso) { +

+ selected collection: {{getDspaceObjectName()}}  + {{'admin.batch-import.page.remove' | translate}} +

+ } +

+ +

+
+
+ + +
+ + {{'admin.batch-import.page.validateOnly.hint' | translate}} + +
- - - {{'admin.batch-import.page.toggle.help' | translate}} - + + + {{'admin.batch-import.page.toggle.help' | translate}} + - @if (isUpload) { - - - } + @if (isUpload) { + + + } - @if (!isUpload) { -
- + @if (!isUpload) { +
+ +
+ } +
+ } + + @if (isUpload && (uploading || stagedUploads.retained?.file)) { +
+

+ {{ (progress.phase === 'starting' ? 'admin.batch-import.upload.starting' : 'admin.batch-import.upload.progress') | translate }} +

+ + + @if (uploading && progress.phase === 'uploading' && progress.bytesPerSecond !== null) { +

+ {{ 'admin.batch-import.upload.speed' | translate: { speed: (progress.bytesPerSecond | dsFileSize:1) } }} +

+ } +
+ } + +
+ @if (uploading) { + + } @else { + + @if (stagedUploads.retained?.file) { + + } + + }
} - -
- - -
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 index a6d2a41b75a..072cd320de3 100644 --- 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 @@ -14,6 +14,8 @@ import { BATCH_IMPORT_SCRIPT_NAME, ScriptDataService, } from '@dspace/core/data/processes/script-data.service'; +import { StagedUploadService } from '@dspace/core/data/staged-upload.service'; +import { LocaleService } from '@dspace/core/locale/locale.service'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { ProcessParameter } from '@dspace/core/processes/process-parameter.model'; import { NotificationsServiceStub } from '@dspace/core/testing/notifications-service.stub'; @@ -22,6 +24,12 @@ import { createSuccessfulRemoteDataObject$, } from '@dspace/core/utilities/remote-data.utils'; import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + of, + Subject, + throwError, +} from 'rxjs'; import { FileDropzoneNoUploaderComponent } from '../../shared/upload/file-dropzone-no-uploader/file-dropzone-no-uploader.component'; import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive'; @@ -34,6 +42,7 @@ describe('BatchImportPageComponent', () => { let notificationService: NotificationsServiceStub; let scriptService: any; + let stagedUploads: any; let router; let locationStub; @@ -44,6 +53,12 @@ describe('BatchImportPageComponent', () => { invoke: createSuccessfulRemoteDataObject$({ processId: '46' }), }, ); + stagedUploads = { + start: jasmine.createSpy('start').and.returnValue(of(46)), + cancel: jasmine.createSpy('cancel').and.returnValue(of(undefined)), + retained: undefined, + progress$: new BehaviorSubject({ phase: 'idle', loaded: 0, total: 0, bytesPerSecond: null }), + }; router = jasmine.createSpyObj('router', { navigateByUrl: jasmine.createSpy('navigateByUrl'), }); @@ -64,6 +79,8 @@ describe('BatchImportPageComponent', () => { providers: [ { provide: NotificationsService, useValue: notificationService }, { provide: ScriptDataService, useValue: scriptService }, + { provide: StagedUploadService, useValue: stagedUploads }, + { provide: LocaleService, useValue: { getCurrentLanguageCode: () => of('en') } }, { provide: Router, useValue: router }, { provide: Location, useValue: locationStub }, ], @@ -126,7 +143,9 @@ describe('BatchImportPageComponent', () => { Object.assign(new ProcessParameter(), { name: '--add' }), Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), ]; - expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + expect(stagedUploads.start).toHaveBeenCalledWith(fileMock, jasmine.any(Function), + { parameters: parameterValues, collectionName: undefined }); + expect(scriptService.invoke).not.toHaveBeenCalled(); }); it('success notification is shown', () => { expect(notificationService.success).toHaveBeenCalled(); @@ -149,7 +168,8 @@ describe('BatchImportPageComponent', () => { Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), Object.assign(new ProcessParameter(), { name: '-v', value: true }), ]; - expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + expect(stagedUploads.start).toHaveBeenCalledWith(fileMock, jasmine.any(Function), + { parameters: parameterValues, collectionName: undefined }); }); it('success notification is shown', () => { expect(notificationService.success).toHaveBeenCalled(); @@ -162,7 +182,7 @@ describe('BatchImportPageComponent', () => { describe('if proceed is pressed; but script invoke fails', () => { beforeEach(fakeAsync(() => { jasmine.getEnv().allowRespy(true); - spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500)); + stagedUploads.start.and.returnValue(throwError(() => new Error('Upload failed'))); const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; proceed.click(); fixture.detectChanges(); @@ -173,6 +193,63 @@ describe('BatchImportPageComponent', () => { }); }); + it('shows the file, collection, progress and speed, and offers only cancellation while uploading', () => { + const process = new Subject(); + const file = new File(['abcdef'], 'batch.zip'); + stagedUploads.start.and.returnValue(process); + component.setFile(file); + component.importMetadata(); + component.importMetadata(); + stagedUploads.retained = { file, context: { parameters: [], collectionName: 'SAF collection' } }; + stagedUploads.progress$.next({ phase: 'uploading', loaded: 3, total: 6, bytesPerSecond: 1024 }); + fixture.detectChanges(); + const page = fixture.nativeElement; + expect(stagedUploads.start).toHaveBeenCalledTimes(1); + expect(page.textContent).not.toContain('admin.batch-import.page.help'); + expect(page.querySelector('[data-test="upload-file"]')).toBeTruthy(); + expect(page.querySelector('[data-test="upload-collection"]').textContent).toContain('admin.batch-import.upload.collection'); + expect(page.querySelector('[data-test="file-dropzone"]')).toBeNull(); + expect(page.querySelector('progress').value).toBe(3); + expect(page.querySelector('[data-test="upload-speed"]')).toBeTruthy(); + expect(page.querySelector('#proceedButton')).toBeNull(); + expect(page.querySelector('#backButton')).toBeNull(); + const cancel = page.querySelector('#cancelUploadButton'); + expect(cancel.classList).toContain('btn-danger'); + expect(cancel.getAttribute('aria-disabled')).not.toBe('true'); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + + stagedUploads.progress$.next({ phase: 'starting', loaded: 6, total: 6, bytesPerSecond: null }); + fixture.detectChanges(); + expect(page.querySelector('[data-test="upload-speed"]')).toBeNull(); + expect(page.querySelector('#saf-upload-label').textContent).toContain('admin.batch-import.upload.starting'); + expect(page.querySelector('#cancelUploadButton').getAttribute('aria-disabled')).toBe('true'); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + process.next(46); + process.complete(); + expect(router.navigateByUrl).toHaveBeenCalledOnceWith('/processes/46'); + expect(component.uploading).toBeFalse(); + }); + + it('offers resume and cancel, and confirms the original target, when a file is retained', () => { + stagedUploads.retained = { file: new File(['abcdef'], 'retained.zip'), context: { parameters: [] } }; + fixture.detectChanges(); + const page = fixture.nativeElement; + expect(page.textContent).not.toContain('admin.batch-import.page.help'); + expect(page.querySelector('[data-test="upload-collection"]').textContent).toContain('admin.batch-import.upload.collection.none'); + expect(page.querySelector('[role="status"]').textContent).toContain('admin.batch-import.upload.resume'); + expect(page.querySelector('[data-test="file-dropzone"]')).toBeNull(); + expect(page.querySelector('#proceedButton')).toBeTruthy(); + expect(page.querySelector('#backButton')).toBeTruthy(); + expect(page.querySelector('#cancelUploadButton').classList).toContain('btn-danger'); + page.querySelector('#cancelUploadButton').click(); + expect(stagedUploads.cancel).toHaveBeenCalled(); + }); + + it('does not round an unacknowledged upload up to 100 percent', () => { + expect(component.uploadPercentage(999, 1000)).toBe(99); + expect(component.uploadPercentage(1000, 1000)).toBe(100); + }); + describe('if url is set', () => { beforeEach(fakeAsync(() => { component.isUpload = false; 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 214f4e4bf6d..31c9c2f892d 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 @@ -1,5 +1,11 @@ -import { Location } from '@angular/common'; -import { Component } from '@angular/core'; +import { + AsyncPipe, + Location, +} from '@angular/common'; +import { + Component, + OnDestroy, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; @@ -7,6 +13,10 @@ import { BATCH_IMPORT_SCRIPT_NAME, ScriptDataService, } from '@dspace/core/data/processes/script-data.service'; +import { + StagedUpload, + StagedUploadService, +} from '@dspace/core/data/staged-upload.service'; import { RemoteData } from '@dspace/core/data/remote-data'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { Process } from '@dspace/core/processes/process.model'; @@ -23,23 +33,42 @@ import { TranslateService, } from '@ngx-translate/core'; import { UiSwitchModule } from 'ngx-ui-switch'; -import { take } from 'rxjs/operators'; +import { Subscription } from 'rxjs'; +import { + finalize, + map, + take, +} from 'rxjs/operators'; import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { ImportBatchSelectorComponent } from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component'; import { FileDropzoneNoUploaderComponent } from '../../shared/upload/file-dropzone-no-uploader/file-dropzone-no-uploader.component'; +import { FileSizePipe } from '../../shared/utils/file-size-pipe'; + +/** What the batch-import page keeps with a staged file, so a resumed upload reuses the original settings. */ +interface RetainedImport { + parameters: ProcessParameter[]; + collectionName?: string; +} @Component({ selector: 'ds-batch-import-page', templateUrl: './batch-import-page.component.html', imports: [ + AsyncPipe, + BtnDisabledDirective, FileDropzoneNoUploaderComponent, + FileSizePipe, FormsModule, TranslateModule, UiSwitchModule, ], }) -export class BatchImportPageComponent { +export class BatchImportPageComponent implements OnDestroy { + /** Prevent duplicate submission while uploading or handing off to the process page. */ + uploading = false; + private uploadSubscription?: Subscription; /** * The current value of the file */ @@ -71,7 +100,9 @@ export class BatchImportPageComponent { private scriptDataService: ScriptDataService, private router: Router, private modalService: NgbModal, - private dsoNameService: DSONameService) { + private dsoNameService: DSONameService, + public stagedUploads: StagedUploadService) { + this.fileObject = stagedUploads.retained?.file; } /** @@ -82,11 +113,23 @@ export class BatchImportPageComponent { this.fileObject = file; } + /** The collection this upload targets, also after returning to the page with a retained file. */ + get collectionName(): string { + return this.dso ? this.getDspaceObjectName() : this.retainedImport?.collectionName; + } + + /** The settings kept with a staged file that is waiting to be resumed. */ + private get retainedImport(): RetainedImport | undefined { + return this.stagedUploads.retained?.context as RetainedImport | undefined; + } + /** * When return button is pressed go to previous location */ public onReturn() { - this.location.back(); + if (!this.uploading) { + this.location.back(); + } } public selectCollection() { @@ -97,9 +140,12 @@ export class BatchImportPageComponent { } /** - * Starts import-metadata script with --zip fileName (and the selected file) + * Upload the SAF before creating an import process, or use the existing URL import path. */ public importMetadata() { + if (this.uploading) { + return; + } if (this.fileObject == null && isEmpty(this.fileURL)) { if (this.isUpload) { this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile')); @@ -123,6 +169,39 @@ export class BatchImportPageComponent { parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true })); } + if (this.isUpload) { + this.uploading = true; + // A retained file resumes with the settings it was started with, not with the hidden form's defaults + const resuming = this.stagedUploads.retained?.file === this.fileObject ? this.retainedImport : undefined; + const context: RetainedImport = resuming ?? { parameters: parameterValues, collectionName: this.getDspaceObjectName() ?? undefined }; + this.uploadSubscription = this.stagedUploads.start(this.fileObject, (upload: StagedUpload) => + this.scriptDataService.invokeWithUploads(BATCH_IMPORT_SCRIPT_NAME, context.parameters, [upload.id]).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (!rd.hasSucceeded) { + throw rd; + } + return rd.payload.processId; + }), + ), context).pipe( + finalize(() => this.uploading = false), + ).subscribe({ + next: (processId) => { + this.notificationsService.success(this.translate.get('process.new.notification.success.title'), + this.translate.get('process.new.notification.success.content')); + void this.router.navigateByUrl(getProcessDetailRoute(String(processId))); + }, + error: (error: unknown) => { + const status = (error as { status?: number; statusCode?: number })?.status + ?? (error as { statusCode?: number })?.statusCode; + const key = status === 401 ? 'admin.batch-import.upload.expired' + : status === 413 ? 'process.new.notification.error.max-upload.content' : 'admin.batch-import.upload.error'; + this.notificationsService.error(this.translate.get('process.new.notification.error.title'), this.translate.get(key)); + }, + }); + return; + } + this.scriptDataService.invoke(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe( getFirstCompletedRemoteData(), ).subscribe((rd: RemoteData) => { @@ -148,6 +227,25 @@ export class BatchImportPageComponent { } } + /** Stop transfer and discard staging; a process already being created is not cancelled. */ + public cancelUpload(): void { + this.uploadSubscription?.unsubscribe(); + this.stagedUploads.cancel().subscribe({ + next: () => this.fileObject = undefined, + error: () => this.notificationsService.error(this.translate.get('admin.batch-import.upload.error')), + }); + } + + /** Stop network transfer when leaving the page; committed chunks remain available for retry. */ + public ngOnDestroy(): void { + this.uploadSubscription?.unsubscribe(); + } + + /** Show 100% only after the final chunk has been acknowledged. */ + public uploadPercentage(loaded: number, total: number): number { + return total > 0 ? Math.floor(loaded * 100 / total) : 0; + } + /** * return selected dspace object name */ diff --git a/src/app/core/data/processes/script-data.service.spec.ts b/src/app/core/data/processes/script-data.service.spec.ts index 0aee3cfb464..82adc27ecd6 100644 --- a/src/app/core/data/processes/script-data.service.spec.ts +++ b/src/app/core/data/processes/script-data.service.spec.ts @@ -6,7 +6,11 @@ * http://www.dspace.org/license/ */ +import { of } from 'rxjs'; + +import { ProcessParameter } from '../../processes/process-parameter.model'; import { testFindAllDataImplementation } from '../base/find-all-data.spec'; +import { PostRequest } from '../request.models'; import { ScriptDataService } from './script-data.service'; describe('ScriptDataService', () => { @@ -14,4 +18,23 @@ describe('ScriptDataService', () => { const initService = () => new ScriptDataService(null, null, null, null); testFindAllDataImplementation(initService); }); + + describe('invokeWithUploads', () => { + it('posts the parameters and upload identifiers as JSON to the script processes endpoint', () => { + const requestService = jasmine.createSpyObj('requestService', { generateRequestId: 'request-id', send: undefined }); + const rdbService = jasmine.createSpyObj('rdbService', { buildFromRequestUUID: of(undefined) }); + const halService = jasmine.createSpyObj('halService', { getEndpoint: of('https://rest.api/api/system/scripts') }); + const service = new ScriptDataService(requestService, rdbService, null, halService); + const parameters = [Object.assign(new ProcessParameter(), { name: '--zip', value: 'batch.zip' })]; + + service.invokeWithUploads('import', parameters, ['upload-id']); + + const request: PostRequest = requestService.send.calls.mostRecent().args[0]; + expect(request).toBeInstanceOf(PostRequest); + expect(request.href).toBe('https://rest.api/api/system/scripts/import/processes'); + expect(request.options.headers.get('Content-Type')).toBe('application/json'); + expect(JSON.parse(request.body)).toEqual({ properties: [{ name: '--zip', value: 'batch.zip' }], uploads: ['upload-id'] }); + expect(rdbService.buildFromRequestUUID).toHaveBeenCalledWith('request-id'); + }); + }); }); diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index 883380be9f0..b292a585616 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -1,3 +1,6 @@ +import { + HttpHeaders, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Script } from '@dspace/core/shared/scripts/script.model'; import { SCRIPT } from '@dspace/core/shared/scripts/script.resource-type'; @@ -25,7 +28,11 @@ import { IdentifiableDataService } from '../base/identifiable-data.service'; import { FindListOptions } from '../find-list-options.model'; import { PaginatedList } from '../paginated-list.model'; import { RemoteData } from '../remote-data'; -import { MultipartPostRequest } from '../request.models'; +import { HttpOptions } from '../../dspace-rest/dspace-rest.service'; +import { + MultipartPostRequest, + PostRequest, +} from '../request.models'; import { RequestService } from '../request.service'; import { RestRequest } from '../rest-request.model'; @@ -65,6 +72,27 @@ export class ScriptDataService extends IdentifiableDataService