diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 59a155cb22..d2a974ea42 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -51,6 +51,8 @@ "admin.registries.schema.title": "DSpace Angular :: Metadata Schema Registry", "auth.errors.invalid-user": "Invalid email address or password.", "auth.messages.expired": "Your session has expired. Please log in again.", + "bitstream.edit.title": "Edit bitstream", + "bitstream.edit.bitstream": "Bitstream: ", "browse.comcol.by.author": "By Author", "browse.comcol.by.dateissued": "By Issue Date", "browse.comcol.by.subject": "By Subject", @@ -177,6 +179,9 @@ "home.top-level-communities.help": "Select a community to browse its collections.", "item.bitstreams.upload.bundle-name": "Bundle Name", "item.bitstreams.upload.drop-message": "Drop a file to upload", + "item.bitstreams.upload.failed": "Upload failed. Please verify the content before retrying.", + "item.bitstreams.upload.item": "Item: ", + "item.bitstreams.upload.title": "Upload bitstream", "item.edit.delete.cancel": "Cancel", "item.edit.delete.confirm": "Delete", "item.edit.delete.description": "Are you sure this item should be completely deleted? Caution: At present, no tombstone would be left.", diff --git a/src/app/+bitstream-page/bitstream-page-routing.module.ts b/src/app/+bitstream-page/bitstream-page-routing.module.ts new file mode 100644 index 0000000000..790530abee --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page-routing.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { BitstreamPageResolver } from './bitstream-page.resolver'; + +const EDIT_BITSTREAM_PATH = ':id/edit'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: EDIT_BITSTREAM_PATH, + component: EditBitstreamPageComponent, + resolve: { + bitstream: BitstreamPageResolver + }, + canActivate: [AuthenticatedGuard] + } + ]) + ], + providers: [ + BitstreamPageResolver, + ] +}) +export class BitstreamPageRoutingModule { +} diff --git a/src/app/+bitstream-page/bitstream-page.module.ts b/src/app/+bitstream-page/bitstream-page.module.ts new file mode 100644 index 0000000000..63656752c9 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; +import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + BitstreamPageRoutingModule + ], + declarations: [ + EditBitstreamPageComponent + ] +}) +export class BitstreamPageModule { +} diff --git a/src/app/+bitstream-page/bitstream-page.resolver.ts b/src/app/+bitstream-page/bitstream-page.resolver.ts new file mode 100644 index 0000000000..8e9f64fcc1 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page.resolver.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { RemoteData } from '../core/data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { find } from 'rxjs/operators'; +import { hasValue } from '../shared/empty.util'; +import { Bitstream } from '../core/shared/bitstream.model'; +import { BitstreamDataService } from '../core/data/bitstream-data.service'; + +/** + * This class represents a resolver that requests a specific bitstream before the route is activated + */ +@Injectable() +export class BitstreamPageResolver implements Resolve> { + constructor(private bitstreamService: BitstreamDataService) { + } + + /** + * Method for resolving a bitstream based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found bitstream based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + return this.bitstreamService.findById(route.params.id) + .pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); + } +} diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html new file mode 100644 index 0000000000..518398f690 --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -0,0 +1,13 @@ +
+
+
+

{{'bitstream.edit.title' | translate}}

+ +
+ {{'bitstream.edit.bitstream' | translate}} + {{bitstream.name}} +
+
+
+
+
diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts new file mode 100644 index 0000000000..e78030d7ff --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -0,0 +1,30 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../core/data/remote-data'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { ActivatedRoute } from '@angular/router'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'ds-edit-bitstream-page', + templateUrl: './edit-bitstream-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +/** + * Page component for editing a bitstream + */ +export class EditBitstreamPageComponent implements OnInit { + + /** + * The bitstream to edit + */ + bitstreamRD$: Observable>; + + constructor(private route: ActivatedRoute) { + } + + ngOnInit(): void { + this.bitstreamRD$ = this.route.data.pipe(map((data) => data.bitstream)); + } + +} diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html index 7bb855c75c..75d31ee34e 100644 --- a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html @@ -1,14 +1,28 @@
-
- - - +
+

{{'item.bitstreams.upload.title' | translate}}

+ +
+ {{'item.bitstreams.upload.item' | translate}} + {{item.name}} +
- +
+
+ + +
diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts index b373a28910..3caf8b586d 100644 --- a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts @@ -1,9 +1,18 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; -import { map } from 'rxjs/operators'; +import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; +import { UploaderOptions } from '../../../shared/uploader/uploader-options.model'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { hasValue, hasValueOperator } from '../../../shared/empty.util'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { AuthService } from '../../../core/auth/auth.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { UploaderProperties } from '../../../shared/uploader/uploader-properties.model'; +import { getBitstreamModulePath } from '../../../app-routing.module'; @Component({ selector: 'ds-upload-bitstream', @@ -13,7 +22,7 @@ import { ActivatedRoute, Router } from '@angular/router'; /** * Page component for uploading a bitstream to an item */ -export class UploadBitstreamComponent implements OnInit { +export class UploadBitstreamComponent implements OnInit, OnDestroy { /** * The item to upload a bitstream to @@ -21,17 +30,82 @@ export class UploadBitstreamComponent implements OnInit { itemRD$: Observable>; /** - * The name of the bundle to add the bitstream to - * Defaults to ORIGINAL + * The uploader configuration options + * @type {UploaderOptions} */ - bundleName$: Observable; + uploadFilesOptions: UploaderOptions = { + url: '', + authToken: null, + disableMultipart: false, + itemAlias: null + }; - constructor(private route: ActivatedRoute) { + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + subs: Subscription[] = []; + + /** + * Properties to send with the upload request + */ + uploadProperties = Object.assign(new UploaderProperties(), { + bundleName: 'ORIGINAL' + }); + + constructor(protected route: ActivatedRoute, + protected router: Router, + protected itemService: ItemDataService, + protected authService: AuthService, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { } ngOnInit(): void { this.itemRD$ = this.route.data.pipe(map((data) => data.item)); - this.bundleName$ = this.route.queryParams.pipe(map((params) => params.bundleName)); + this.subs.push( + this.route.queryParams.pipe( + map((params) => params.bundleName), + hasValueOperator(), + distinctUntilChanged() + ).subscribe((bundleName: string) => { + this.uploadProperties.bundleName = bundleName; + }) + ); + this.subs.push( + this.itemRD$.pipe( + map((itemRD: RemoteData) => itemRD.payload), + switchMap((item: Item) => this.itemService.getBitstreamsEndpoint(item.id)), + distinctUntilChanged() + ).subscribe((url: string) => { + this.uploadFilesOptions.url = url; + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + }) + ); + } + + /** + * The request was successful, redirect the user to the new bitstream's edit page + * @param bitstream + */ + public onCompleteItem(bitstream) { + this.router.navigate([getBitstreamModulePath(), bitstream.id, 'edit']); + } + + /** + * The request was unsuccessful, display an error notification + */ + public onUploadError() { + this.notificationsService.error(null, this.translate.get('item.bitstreams.upload.failed')); + } + + /** + * Unsubscribe from all open subscriptions when the component is destroyed + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); } } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 86364aca89..a02a9f85b8 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -16,6 +16,10 @@ const COMMUNITY_MODULE_PATH = 'communities'; export function getCommunityModulePath() { return `/${COMMUNITY_MODULE_PATH}`; } +const BITSTREAM_MODULE_PATH = 'bitstreams'; +export function getBitstreamModulePath() { + return `/${BITSTREAM_MODULE_PATH}`; +} @NgModule({ imports: [ RouterModule.forRoot([ @@ -24,6 +28,7 @@ export function getCommunityModulePath() { { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, + { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' }, { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 1d22b5fefe..fc6a6d5f9a 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -117,6 +117,7 @@ import { MetadatafieldParsingService } from './data/metadatafield-parsing.servic import { NormalizedSubmissionUploadsModel } from './config/models/normalized-config-submission-uploads.model'; import { NormalizedBrowseEntry } from './shared/normalized-browse-entry.model'; import { BrowseDefinition } from './shared/browse-definition.model'; +import { BitstreamDataService } from './data/bitstream-data.service'; const IMPORTS = [ CommonModule, @@ -203,6 +204,7 @@ const PROVIDERS = [ TaskResponseParsingService, ClaimedTaskDataService, PoolTaskDataService, + BitstreamDataService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts new file mode 100644 index 0000000000..361c38861a --- /dev/null +++ b/src/app/core/data/bitstream-data.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core'; +import { DataService } from './data.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { BrowseService } from '../browse/browse.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindAllOptions } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the bitstreams endpoint + */ +@Injectable() +export class BitstreamDataService extends DataService { + protected linkPath = 'bitstreams'; + protected forceBypassCache = false; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store, + protected bs: BrowseService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + super(); + } + + /** + * Get the endpoint for browsing bitstreams + * @param {FindAllOptions} options + * @param linkPath + * @returns {Observable} + */ + getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + return this.halService.getEndpoint(linkPath); + } +} diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index f6adbb23c2..7b2eaeed7e 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -118,4 +118,14 @@ export class ItemDataService extends DataService { map((requestEntry: RequestEntry) => requestEntry.response) ); } + + /** + * Get the endpoint for an item's bitstreams + * @param itemId + */ + public getBitstreamsEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((url: string) => `${url}/${itemId}/bitstreams`) + ); + } } diff --git a/src/app/shared/uploader/uploader-properties.model.ts b/src/app/shared/uploader/uploader-properties.model.ts new file mode 100644 index 0000000000..bc0376b809 --- /dev/null +++ b/src/app/shared/uploader/uploader-properties.model.ts @@ -0,0 +1,21 @@ +import { MetadataMap } from '../../core/shared/metadata.models'; + +/** + * Properties to send to the REST API for uploading a bitstream + */ +export class UploaderProperties { + /** + * A custom name for the bitstream + */ + name: string; + + /** + * Metadata for the bitstream (e.g. dc.description) + */ + metadata: MetadataMap; + + /** + * The name of the bundle to upload the bitstream to + */ + bundleName: string; +} diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index ad52f4a93f..a8306a40de 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -15,8 +15,9 @@ import { uniqueId } from 'lodash'; import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; import { UploaderOptions } from './uploader-options.model'; -import { isNotEmpty, isUndefined } from '../empty.util'; +import { hasValue, isNotEmpty, isUndefined } from '../empty.util'; import { UploaderService } from './uploader.service'; +import { UploaderProperties } from './uploader-properties.model'; @Component({ selector: 'ds-uploader', @@ -53,6 +54,11 @@ export class UploaderComponent { */ @Input() uploadFilesOptions: UploaderOptions; + /** + * Extra properties to be passed with the form-data of the upload + */ + @Input() uploadProperties: UploaderProperties; + /** * The function to call when upload is completed */ @@ -127,6 +133,11 @@ export class UploaderComponent { }; this.scrollToService.scrollTo(config); }; + if (hasValue(this.uploadProperties)) { + this.uploader.onBuildItemForm = (item, form) => { + form.append('properties', JSON.stringify(this.uploadProperties)) + }; + } this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => { if (isNotEmpty(response)) { const responsePath = JSON.parse(response);