diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 81b0755d11..db6b22a023 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -4,7 +4,7 @@ import { Collection } from './core/shared/collection.model'; import { Item } from './core/shared/item.model'; import { getCommunityPageRoute } from './community-page/community-page-routing-paths'; import { getCollectionPageRoute } from './collection-page/collection-page-routing-paths'; -import { getItemPageRoute } from './item-page/item-page-routing-paths'; +import { getItemModuleRoute, getItemPageRoute } from './item-page/item-page-routing-paths'; import { hasValue } from './shared/empty.util'; import { URLCombiner } from './core/url-combiner/url-combiner'; @@ -22,6 +22,15 @@ export function getBitstreamModuleRoute() { export function getBitstreamDownloadRoute(bitstream): string { return new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(); } +export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: string, queryParams: any } { + const url = new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString(); + return { + routerLink: url, + queryParams: { + bitstream: bitstream.uuid + } + }; +} export const ADMIN_MODULE_PATH = 'admin'; @@ -90,3 +99,8 @@ export const ACCESS_CONTROL_MODULE_PATH = 'access-control'; export function getAccessControlModuleRoute() { return `/${ACCESS_CONTROL_MODULE_PATH}`; } + +export const REQUEST_COPY_MODULE_PATH = 'request-a-copy'; +export function getRequestCopyModulePath() { + return `/${REQUEST_COPY_MODULE_PATH}`; +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 52a07b89f5..157ada622d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -14,7 +14,7 @@ import { PROFILE_MODULE_PATH, REGISTER_PATH, WORKFLOW_ITEM_MODULE_PATH, - LEGACY_BITSTREAM_MODULE_PATH, + LEGACY_BITSTREAM_MODULE_PATH, REQUEST_COPY_MODULE_PATH, } from './app-routing-paths'; import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths'; import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths'; @@ -180,6 +180,11 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu path: INFO_MODULE_PATH, loadChildren: () => import('./info/info.module').then((m) => m.InfoModule), }, + { + path: REQUEST_COPY_MODULE_PATH, + loadChildren: () => import('./request-copy/request-copy.module').then((m) => m.RequestCopyModule), + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, { path: FORBIDDEN_PATH, component: ThemedForbiddenComponent diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 4716fbf413..15eba0e5db 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -14,6 +14,7 @@ export enum FeatureID { IsCollectionAdmin = 'isCollectionAdmin', IsCommunityAdmin = 'isCommunityAdmin', CanDownload = 'canDownload', + CanRequestACopy = 'canRequestACopy', CanManageVersions = 'canManageVersions', CanManageBitstreamBundles = 'canManageBitstreamBundles', CanManageRelationships = 'canManageRelationships', diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts new file mode 100644 index 0000000000..0d99ca5cd4 --- /dev/null +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -0,0 +1,95 @@ +import { ItemRequestDataService } from './item-request-data.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ItemRequest } from '../shared/item-request.model'; +import { PostRequest } from './request.models'; +import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { RestRequestMethod } from './rest-request-method'; + +describe('ItemRequestDataService', () => { + let service: ItemRequestDataService; + + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let halService: HALEndpointService; + + const restApiEndpoint = 'rest/api/endpoint/'; + const requestId = 'request-id'; + let itemRequest: ItemRequest; + + beforeEach(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + }); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestId, + send: '', + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildFromRequestUUID: createSuccessfulRemoteDataObject$(itemRequest), + }); + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(restApiEndpoint), + }); + + service = new ItemRequestDataService(requestService, rdbService, null, null, halService, null, null, null); + }); + + describe('requestACopy', () => { + it('should send a POST request containing the provided item request', (done) => { + service.requestACopy(itemRequest).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest)); + done(); + }); + }); + }); + + describe('grant', () => { + let email: RequestCopyEmail; + + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + }); + + it('should send a PUT request containing the correct properties', (done) => { + service.grant(itemRequest.token, email, true).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + body: JSON.stringify({ + acceptRequest: true, + responseMessage: email.message, + subject: email.subject, + suggestOpenAccess: true, + }), + })); + done(); + }); + }); + }); + + describe('deny', () => { + let email: RequestCopyEmail; + + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + }); + + it('should send a PUT request containing the correct properties', (done) => { + service.deny(itemRequest.token, email).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + body: JSON.stringify({ + acceptRequest: false, + responseMessage: email.message, + subject: email.subject, + suggestOpenAccess: false, + }), + })); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts new file mode 100644 index 0000000000..41ad19211a --- /dev/null +++ b/src/app/core/data/item-request-data.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, find, map } from 'rxjs/operators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData, sendRequest } from '../shared/operators'; +import { RemoteData } from './remote-data'; +import { PostRequest, PutRequest } from './request.models'; +import { RequestService } from './request.service'; +import { ItemRequest } from '../shared/item-request.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { DataService } from './data.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint + */ +@Injectable( + { + providedIn: 'root', + } +) +export class ItemRequestDataService extends DataService { + + protected linkPath = 'itemrequests'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + ) { + super(); + } + + getItemRequestEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the endpoint for an {@link ItemRequest} by their token + * @param token + */ + getItemRequestEndpointByToken(token: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => `${href}/${token}`)); + } + + /** + * Request a copy of an item + * @param itemRequest + */ + requestACopy(itemRequest: ItemRequest): Observable> { + const requestId = this.requestService.generateRequestId(); + + const href$ = this.getItemRequestEndpoint(); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, itemRequest); + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId).pipe( + getFirstCompletedRemoteData() + ); + } + + /** + * Deny the request of an item + * @param token Token of the {@link ItemRequest} + * @param email Email to send back to the user requesting the item + */ + deny(token: string, email: RequestCopyEmail): Observable> { + return this.process(token, email, false); + } + + /** + * Grant the request of an item + * @param token Token of the {@link ItemRequest} + * @param email Email to send back to the user requesting the item + * @param suggestOpenAccess Whether or not to suggest the item to become open access + */ + grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable> { + return this.process(token, email, true, suggestOpenAccess); + } + + /** + * Process the request of an item + * @param token Token of the {@link ItemRequest} + * @param email Email to send back to the user requesting the item + * @param grant Grant or deny the request (true = grant, false = deny) + * @param suggestOpenAccess Whether or not to suggest the item to become open access + */ + process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable> { + const requestId = this.requestService.generateRequestId(); + + this.getItemRequestEndpointByToken(token).pipe( + distinctUntilChanged(), + map((endpointURL: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new PutRequest(requestId, endpointURL, JSON.stringify({ + acceptRequest: grant, + responseMessage: email.message, + subject: email.subject, + suggestOpenAccess, + }), options); + }), + sendRequest(this.requestService)).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + +} diff --git a/src/app/core/shared/item-request.model.ts b/src/app/core/shared/item-request.model.ts new file mode 100644 index 0000000000..08b65abebf --- /dev/null +++ b/src/app/core/shared/item-request.model.ts @@ -0,0 +1,90 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { ResourceType } from './resource-type'; +import { ITEM_REQUEST } from './item-request.resource-type'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { HALLink } from './hal-link.model'; + +/** + * Model class for an ItemRequest + */ +@typedObject +export class ItemRequest implements CacheableObject { + static type = ITEM_REQUEST; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * opaque string which uniquely identifies this request + */ + @autoserialize + token: string; + + /** + * true if the request is for all bitstreams of the item. + */ + @autoserialize + allfiles: boolean; + /** + * email address of the person requesting the files. + */ + @autoserialize + requestEmail: string; + /** + * Human-readable name of the person requesting the files. + */ + @autoserialize + requestName: string; + /** + * arbitrary message provided by the person requesting the files. + */ + @autoserialize + requestMessage: string; + /** + * date that the request was recorded. + */ + @autoserialize + requestDate: string; + /** + * true if the request has been granted. + */ + @autoserialize + acceptRequest: boolean; + /** + * date that the request was granted or denied. + */ + @autoserialize + decisionDate: string; + /** + * date on which the request is considered expired. + */ + @autoserialize + expires: string; + /** + * UUID of the requested Item. + */ + @autoserialize + itemId: string; + /** + * UUID of the requested bitstream. + */ + @autoserialize + bitstreamId: string; + + /** + * The {@link HALLink}s for this ItemRequest + */ + @deserialize + _links: { + self: HALLink; + item: HALLink; + bitstream: HALLink; + }; + +} diff --git a/src/app/core/shared/item-request.resource-type.ts b/src/app/core/shared/item-request.resource-type.ts new file mode 100644 index 0000000000..0535ef1948 --- /dev/null +++ b/src/app/core/shared/item-request.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ItemRequest. + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ITEM_REQUEST = new ResourceType('itemrequest'); diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.html b/src/app/item-page/full/field-components/file-section/full-file-section.component.html index c5393055df..33acd6650b 100644 --- a/src/app/item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.html @@ -33,7 +33,7 @@
- + {{"item.page.filesection.download" | translate}}
@@ -74,7 +74,7 @@
- + {{"item.page.filesection.download" | translate}}
diff --git a/src/app/item-page/item-page-routing.module.ts b/src/app/item-page/item-page-routing.module.ts index 52de34e8ed..2c6631dd1a 100644 --- a/src/app/item-page/item-page-routing.module.ts +++ b/src/app/item-page/item-page-routing.module.ts @@ -14,6 +14,7 @@ import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { VersionPageComponent } from './version-page/version-page/version-page.component'; +import { BitstreamRequestACopyPageComponent } from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; @NgModule({ imports: [ @@ -44,6 +45,10 @@ import { VersionPageComponent } from './version-page/version-page/version-page.c path: UPLOAD_BITSTREAM_PATH, component: UploadBitstreamComponent, canActivate: [AuthenticatedGuard] + }, + { + path: ':request-a-copy', + component: BitstreamRequestACopyPageComponent, } ], data: { diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.html b/src/app/item-page/simple/field-components/file-section/file-section.component.html index 0fa5daa012..3d093f83c9 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.html @@ -1,7 +1,7 @@
- + {{file?.name}} ({{(file?.sizeBytes) | dsFileSize }}) diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.html b/src/app/request-copy/deny-request-copy/deny-request-copy.component.html new file mode 100644 index 0000000000..b00bc079dd --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.html @@ -0,0 +1,9 @@ +
+

{{'deny-request-copy.header' | translate}}

+
+

{{'deny-request-copy.intro' | translate}}

+ + +
+ +
diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.scss b/src/app/request-copy/deny-request-copy/deny-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts b/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts new file mode 100644 index 0000000000..c88bfd3b5e --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts @@ -0,0 +1,177 @@ +import { DenyRequestCopyComponent } from './deny-request-copy.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { of as observableOf } from 'rxjs'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { Item } from '../../core/shared/item.model'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; + +describe('DenyRequestCopyComponent', () => { + let component: DenyRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let translateService: TranslateService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + let itemRequestService: ItemRequestDataService; + let notificationsService: NotificationsService; + + let itemRequest: ItemRequest; + let user: EPerson; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + user = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: 'first' + } + ], + 'eperson.lastname': [ + { + value: 'last' + } + ] + }, + email: 'user-email', + }); + itemName = 'item-name'; + itemUrl = 'item-url'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl'), + }); + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(user), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + itemRequestService = jasmine.createSpyObj('itemRequestService', { + deny: createSuccessfulRemoteDataObject$(itemRequest), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); + + TestBed.configureTestingModule({ + declarations: [DenyRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DenyRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + translateService = (component as any).translateService; + spyOn(translateService, 'get').and.returnValue(observableOf('translated-message')); + }); + + it('message$ should be parameterized correctly', (done) => { + component.message$.subscribe(() => { + expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: itemUrl, + itemName: itemName, + authorName: user.name, + authorEmail: user.email, + })); + done(); + }); + }); + + describe('deny', () => { + let email: RequestCopyEmail; + + describe('when the request is successful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.deny as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(itemRequest)); + component.deny(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should navigate to the homepage', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/'); + }); + }); + + describe('when the request is unsuccessful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.deny as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + component.deny(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + + it('should not navigate', () => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts b/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts new file mode 100644 index 0000000000..0795cf5919 --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts @@ -0,0 +1,112 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { + getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Item } from '../../core/shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-deny-request-copy', + styleUrls: ['./deny-request-copy.component.scss'], + templateUrl: './deny-request-copy.component.html' +}) +/** + * Component for denying an item request + */ +export class DenyRequestCopyComponent implements OnInit { + /** + * The item request to deny + */ + itemRequestRD$: Observable>; + + /** + * The default subject of the message to send to the user requesting the item + */ + subject$: Observable; + /** + * The default contents of the message to send to the user requesting the item + */ + message$: Observable; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private translateService: TranslateService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + private itemRequestService: ItemRequestDataService, + private notificationsService: NotificationsService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + + const msgParams$ = observableCombineLatest( + this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()), + this.authService.getAuthenticatedUserFromStore(), + ).pipe( + switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => { + return this.itemDataService.findById(itemRequest.itemId).pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => { + const uri = item.firstMetadataValue('dc.identifier.uri'); + return Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: isNotEmpty(uri) ? uri : item.handle, + itemName: this.nameService.getName(item), + authorName: user.name, + authorEmail: user.email, + }); + }), + ); + }), + ); + + this.subject$ = this.translateService.get('deny-request-copy.email.subject'); + this.message$ = msgParams$.pipe( + switchMap((params) => this.translateService.get('deny-request-copy.email.message', params)), + ); + } + + /** + * Deny the item request + * @param email Subject and contents of the message to send back to the user requesting the item + */ + deny(email: RequestCopyEmail) { + this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemRequestService.deny(itemRequest.token, email)), + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('deny-request-copy.success')); + this.router.navigateByUrl('/'); + } else { + this.notificationsService.error(this.translateService.get('deny-request-copy.error'), rd.errorMessage); + } + }); + } + +} diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.html b/src/app/request-copy/email-request-copy/email-request-copy.component.html new file mode 100644 index 0000000000..d7633b0334 --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.html @@ -0,0 +1,30 @@ +
+
+ + +
+ {{ 'grant-deny-request-copy.email.subject.empty' | translate }} +
+
+
+ + +
+ {{ 'grant-deny-request-copy.email.message.empty' | translate }} +
+
+ +
+ + +
+
diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.scss b/src/app/request-copy/email-request-copy/email-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts new file mode 100644 index 0000000000..3857c0d91b --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts @@ -0,0 +1,47 @@ +import { EmailRequestCopyComponent } from './email-request-copy.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Location } from '@angular/common'; +import { RequestCopyEmail } from './request-copy-email.model'; + +describe('EmailRequestCopyComponent', () => { + let component: EmailRequestCopyComponent; + let fixture: ComponentFixture; + + let location: Location; + + beforeEach(waitForAsync(() => { + location = jasmine.createSpyObj('location', ['back']); + + TestBed.configureTestingModule({ + declarations: [EmailRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Location, useValue: location }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EmailRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('return should navigate to the previous page', () => { + component.return(); + expect(location.back).toHaveBeenCalled(); + }); + + it('submit should emit an email object', () => { + spyOn(component.send, 'emit').and.stub(); + component.subject = 'test-subject'; + component.message = 'test-message'; + component.submit(); + expect(component.send.emit).toHaveBeenCalledWith(new RequestCopyEmail('test-subject', 'test-message')); + }); +}); diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.ts new file mode 100644 index 0000000000..ab2c8b4526 --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.ts @@ -0,0 +1,45 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { RequestCopyEmail } from './request-copy-email.model'; +import { Location } from '@angular/common'; + +@Component({ + selector: 'ds-email-request-copy', + styleUrls: ['./email-request-copy.component.scss'], + templateUrl: './email-request-copy.component.html' +}) +/** + * A form component for an email to send back to the user requesting an item + */ +export class EmailRequestCopyComponent { + /** + * Event emitter for sending the email + */ + @Output() send: EventEmitter = new EventEmitter(); + + /** + * The subject of the email + */ + @Input() subject: string; + + /** + * The contents of the email + */ + @Input() message: string; + + constructor(protected location: Location) { + } + + /** + * Submit the email + */ + submit() { + this.send.emit(new RequestCopyEmail(this.subject, this.message)); + } + + /** + * Return to the previous page + */ + return() { + this.location.back(); + } +} diff --git a/src/app/request-copy/email-request-copy/request-copy-email.model.ts b/src/app/request-copy/email-request-copy/request-copy-email.model.ts new file mode 100644 index 0000000000..3ae83e8815 --- /dev/null +++ b/src/app/request-copy/email-request-copy/request-copy-email.model.ts @@ -0,0 +1,8 @@ +/** + * A class representing an email to send back to the user requesting an item + */ +export class RequestCopyEmail { + constructor(public subject: string, + public message: string) { + } +} diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html new file mode 100644 index 0000000000..37b275d8f8 --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html @@ -0,0 +1,30 @@ +
+

{{'grant-deny-request-copy.header' | translate}}

+
+ +
+

{{'grant-deny-request-copy.processed' | translate}}

+

+ {{'grant-deny-request-copy.home-page' | translate}} +

+
+
+ +
diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.scss b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts new file mode 100644 index 0000000000..5c37a86f24 --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts @@ -0,0 +1,141 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { of as observableOf } from 'rxjs'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Item } from '../../core/shared/item.model'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy.component'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; +import { getRequestCopyDenyRoute, getRequestCopyGrantRoute } from '../request-copy-routing-paths'; +import { By } from '@angular/platform-browser'; + +describe('GrantDenyRequestCopyComponent', () => { + let component: GrantDenyRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + + let itemRequest: ItemRequest; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + itemName = 'item-name'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + itemUrl = getItemPageRoute(item); + + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + + TestBed.configureTestingModule({ + declarations: [GrantDenyRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GrantDenyRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + router = (component as any).router; + spyOn(router, 'navigateByUrl').and.stub(); + }); + + it('should initialise itemName$', (done) => { + component.itemName$.subscribe((result) => { + expect(result).toEqual(itemName); + done(); + }); + }); + + it('should initialise itemUrl$', (done) => { + component.itemUrl$.subscribe((result) => { + expect(result).toEqual(itemUrl); + done(); + }); + }); + + it('should initialise denyRoute$', (done) => { + component.denyRoute$.subscribe((result) => { + expect(result).toEqual(getRequestCopyDenyRoute(itemRequest.token)); + done(); + }); + }); + + it('should initialise grantRoute$', (done) => { + component.grantRoute$.subscribe((result) => { + expect(result).toEqual(getRequestCopyGrantRoute(itemRequest.token)); + done(); + }); + }); + + describe('processed message', () => { + it('should not be displayed when decisionDate is undefined', () => { + const message = fixture.debugElement.query(By.css('.processed-message')); + expect(message).toBeNull(); + }); + + it('should be displayed when decisionDate is defined', () => { + component.itemRequestRD$ = createSuccessfulRemoteDataObject$(Object.assign(new ItemRequest(), itemRequest, { + decisionDate: 'defined-date' + })); + fixture.detectChanges(); + + const message = fixture.debugElement.query(By.css('.processed-message')); + expect(message).not.toBeNull(); + }); + }); +}); diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts new file mode 100644 index 0000000000..5ba81e991b --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts @@ -0,0 +1,97 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { getRequestCopyDenyRoute, getRequestCopyGrantRoute } from '../request-copy-routing-paths'; +import { Item } from '../../core/shared/item.model'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; + +@Component({ + selector: 'ds-grant-deny-request-copy', + styleUrls: ['./grant-deny-request-copy.component.scss'], + templateUrl: './grant-deny-request-copy.component.html' +}) +/** + * Component for an author to decide to grant or deny an item request + */ +export class GrantDenyRequestCopyComponent implements OnInit { + /** + * The item request to grant or deny + */ + itemRequestRD$: Observable>; + + /** + * The item the request is requesting access to + */ + itemRD$: Observable>; + + /** + * The name of the item + */ + itemName$: Observable; + + /** + * The url of the item + */ + itemUrl$: Observable; + + /** + * The route to the page for denying access to the item + */ + denyRoute$: Observable; + + /** + * The route to the page for granting access to the item + */ + grantRoute$: Observable; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + this.itemRD$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemDataService.findById(itemRequest.itemId)), + ); + this.itemName$ = this.itemRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((item) => this.nameService.getName(item)), + ); + this.itemUrl$ = this.itemRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((item) => getItemPageRoute(item)), + ); + + this.denyRoute$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((itemRequest: ItemRequest) => getRequestCopyDenyRoute(itemRequest.token)) + ); + this.grantRoute$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((itemRequest: ItemRequest) => getRequestCopyGrantRoute(itemRequest.token)) + ); + } + +} diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.html b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html new file mode 100644 index 0000000000..d2c2cfc3c8 --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html @@ -0,0 +1,17 @@ +
+

{{'grant-request-copy.header' | translate}}

+
+

{{'grant-request-copy.intro' | translate}}

+ + +

{{ 'grant-deny-request-copy.email.permissions.info' | translate }}

+
+
+ + +
+
+
+
+ +
diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.scss b/src/app/request-copy/grant-request-copy/grant-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts new file mode 100644 index 0000000000..b6ccb8557e --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts @@ -0,0 +1,177 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { of as observableOf } from 'rxjs'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { Item } from '../../core/shared/item.model'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { GrantRequestCopyComponent } from './grant-request-copy.component'; + +describe('GrantRequestCopyComponent', () => { + let component: GrantRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let translateService: TranslateService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + let itemRequestService: ItemRequestDataService; + let notificationsService: NotificationsService; + + let itemRequest: ItemRequest; + let user: EPerson; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + user = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: 'first' + } + ], + 'eperson.lastname': [ + { + value: 'last' + } + ] + }, + email: 'user-email', + }); + itemName = 'item-name'; + itemUrl = 'item-url'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl'), + }); + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(user), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + itemRequestService = jasmine.createSpyObj('itemRequestService', { + grant: createSuccessfulRemoteDataObject$(itemRequest), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); + + TestBed.configureTestingModule({ + declarations: [GrantRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GrantRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + translateService = (component as any).translateService; + spyOn(translateService, 'get').and.returnValue(observableOf('translated-message')); + }); + + it('message$ should be parameterized correctly', (done) => { + component.message$.subscribe(() => { + expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: itemUrl, + itemName: itemName, + authorName: user.name, + authorEmail: user.email, + })); + done(); + }); + }); + + describe('grant', () => { + let email: RequestCopyEmail; + + describe('when the request is successful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.grant as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(itemRequest)); + component.grant(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should navigate to the homepage', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/'); + }); + }); + + describe('when the request is unsuccessful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.grant as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + component.grant(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + + it('should not navigate', () => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts new file mode 100644 index 0000000000..e3a4614f5d --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts @@ -0,0 +1,118 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { + getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Item } from '../../core/shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-grant-request-copy', + styleUrls: ['./grant-request-copy.component.scss'], + templateUrl: './grant-request-copy.component.html' +}) +/** + * Component for granting an item request + */ +export class GrantRequestCopyComponent implements OnInit { + /** + * The item request to accept + */ + itemRequestRD$: Observable>; + + /** + * The default subject of the message to send to the user requesting the item + */ + subject$: Observable; + /** + * The default contents of the message to send to the user requesting the item + */ + message$: Observable; + + /** + * Whether or not the item should be open access, to avoid future requests + * Defaults to false + */ + suggestOpenAccess = false; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private translateService: TranslateService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + private itemRequestService: ItemRequestDataService, + private notificationsService: NotificationsService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + + const msgParams$ = observableCombineLatest( + this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()), + this.authService.getAuthenticatedUserFromStore(), + ).pipe( + switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => { + return this.itemDataService.findById(itemRequest.itemId).pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => { + const uri = item.firstMetadataValue('dc.identifier.uri'); + return Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: isNotEmpty(uri) ? uri : item.handle, + itemName: this.nameService.getName(item), + authorName: user.name, + authorEmail: user.email, + }); + }), + ); + }), + ); + + this.subject$ = this.translateService.get('grant-request-copy.email.subject'); + this.message$ = msgParams$.pipe( + switchMap((params) => this.translateService.get('grant-request-copy.email.message', params)), + ); + } + + /** + * Grant the item request + * @param email Subject and contents of the message to send back to the user requesting the item + */ + grant(email: RequestCopyEmail) { + this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess)), + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('grant-request-copy.success')); + this.router.navigateByUrl('/'); + } else { + this.notificationsService.error(this.translateService.get('grant-request-copy.error'), rd.errorMessage); + } + }); + } + +} diff --git a/src/app/request-copy/request-copy-routing-paths.ts b/src/app/request-copy/request-copy-routing-paths.ts new file mode 100644 index 0000000000..1d0204a1b8 --- /dev/null +++ b/src/app/request-copy/request-copy-routing-paths.ts @@ -0,0 +1,18 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getRequestCopyModulePath } from '../app-routing-paths'; + +export function getRequestCopyRoute(token: string) { + return new URLCombiner(getRequestCopyModulePath(), token).toString(); +} + +export const REQUEST_COPY_DENY_PATH = 'deny'; + +export function getRequestCopyDenyRoute(token: string) { + return new URLCombiner(getRequestCopyRoute(token), REQUEST_COPY_DENY_PATH).toString(); +} + +export const REQUEST_COPY_GRANT_PATH = 'grant'; + +export function getRequestCopyGrantRoute(token: string) { + return new URLCombiner(getRequestCopyRoute(token), REQUEST_COPY_GRANT_PATH).toString(); +} diff --git a/src/app/request-copy/request-copy-routing.module.ts b/src/app/request-copy/request-copy-routing.module.ts new file mode 100644 index 0000000000..e7a205d0aa --- /dev/null +++ b/src/app/request-copy/request-copy-routing.module.ts @@ -0,0 +1,40 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { RequestCopyResolver } from './request-copy.resolver'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component'; +import { REQUEST_COPY_DENY_PATH, REQUEST_COPY_GRANT_PATH } from './request-copy-routing-paths'; +import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component'; +import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: ':token', + resolve: { + request: RequestCopyResolver + }, + children: [ + { + path: '', + component: GrantDenyRequestCopyComponent, + }, + { + path: REQUEST_COPY_DENY_PATH, + component: DenyRequestCopyComponent, + }, + { + path: REQUEST_COPY_GRANT_PATH, + component: GrantRequestCopyComponent, + }, + ] + } + ]) + ], + providers: [ + RequestCopyResolver, + GrantDenyRequestCopyComponent + ] +}) +export class RequestCopyRoutingModule { +} diff --git a/src/app/request-copy/request-copy.module.ts b/src/app/request-copy/request-copy.module.ts new file mode 100644 index 0000000000..d55d5ad83f --- /dev/null +++ b/src/app/request-copy/request-copy.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component'; +import { RequestCopyRoutingModule } from './request-copy-routing.module'; +import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component'; +import { EmailRequestCopyComponent } from './email-request-copy/email-request-copy.component'; +import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + RequestCopyRoutingModule + ], + declarations: [ + GrantDenyRequestCopyComponent, + DenyRequestCopyComponent, + EmailRequestCopyComponent, + GrantRequestCopyComponent, + ], + providers: [] +}) + +/** + * Module related to components used to grant or deny an item request + */ +export class RequestCopyModule { + +} diff --git a/src/app/request-copy/request-copy.resolver.ts b/src/app/request-copy/request-copy.resolver.ts new file mode 100644 index 0000000000..eb5c5cf0f0 --- /dev/null +++ b/src/app/request-copy/request-copy.resolver.ts @@ -0,0 +1,26 @@ +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { RemoteData } from '../core/data/remote-data'; +import { ItemRequest } from '../core/shared/item-request.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { ItemRequestDataService } from '../core/data/item-request-data.service'; +import { Injectable } from '@angular/core'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; + +/** + * Resolves an {@link ItemRequest} from the token found in the route's parameters + */ +@Injectable() +export class RequestCopyResolver implements Resolve> { + + constructor( + private itemRequestDataService: ItemRequestDataService, + ) { + } + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> | Promise> | RemoteData { + return this.itemRequestDataService.findById(route.params.token).pipe( + getFirstCompletedRemoteData(), + ); + } + +} diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html new file mode 100644 index 0000000000..1fae737fdb --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html @@ -0,0 +1,87 @@ +
+

{{'bitstream-request-a-copy.header' | translate}}

+
+ {{'bitstream-request-a-copy.alert.canDownload1' | translate}} + {{'bitstream-request-a-copy.alert.canDownload2'| translate}} +
+
+

{{'bitstream-request-a-copy.intro' | translate}} {{itemName}}

+

{{'bitstream-request-a-copy.intro.bitstream.one' | translate}} {{bitstreamName}}

+

{{'bitstream-request-a-copy.intro.bitstream.all' | translate}}

+
+
+ +
+
+
+ + +
+ + {{ 'bitstream-request-a-copy.name.error' | translate }} + +
+
+
+
+
+ + +
+ + {{ 'bitstream-request-a-copy.email.error' | translate }} + +
+ {{'bitstream-request-a-copy.email.hint' |translate}} +
+
+
+
+
{{'bitstream-request-a-copy.allfiles.label' |translate}}
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ + + {{'bitstream-request-a-copy.return' | translate}} + + + +
+
+
diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts new file mode 100644 index 0000000000..cc44ef8587 --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts @@ -0,0 +1,289 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { AuthService } from '../../core/auth/auth.service'; +import { of as observableOf } from 'rxjs'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page.component'; +import { By } from '@angular/platform-browser'; +import { RouterStub } from '../testing/router.stub'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NotificationsServiceStub } from '../testing/notifications-service.stub'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSONameServiceMock } from '../mocks/dso-name.service.mock'; +import { Item } from '../../core/shared/item.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Location } from '@angular/common'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; + + +describe('BitstreamRequestACopyPageComponent', () => { + let component: BitstreamRequestACopyPageComponent; + let fixture: ComponentFixture; + + let authService: AuthService; + let authorizationService: AuthorizationDataService; + let activatedRoute; + let router; + let itemRequestDataService; + let notificationsService; + let location; + let bitstreamDataService; + + let item: Item; + let bitstream: Bitstream; + let eperson; + + function init() { + eperson = Object.assign(new EPerson(), { + email: 'test@mail.org', + metadata: { + 'eperson.firstname': [{value: 'Test'}], + 'eperson.lastname': [{value: 'User'}], + } + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(false), + getAuthenticatedUserFromStore: observableOf(eperson) + }); + authorizationService = jasmine.createSpyObj('authorizationSerivice', { + isAuthorized: observableOf(true) + }); + + itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { + requestACopy: createSuccessfulRemoteDataObject$({}) + }); + + location = jasmine.createSpyObj('location', { + back: {} + }); + + notificationsService = new NotificationsServiceStub(); + + item = Object.assign(new Item(), {uuid: 'item-uuid'}); + + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUuid', + _links: { + content: {href: 'bitstream-content-link'}, + self: {href: 'bitstream-self-link'}, + } + }); + + activatedRoute = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject( + item + ) + }), + queryParams: observableOf({ + bitstream : bitstream.uuid + }) + }; + + bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findById: createSuccessfulRemoteDataObject$(bitstream) + }); + + router = new RouterStub(); + } + + function initTestbed() { + TestBed.configureTestingModule({ + imports: [CommonModule, TranslateModule.forRoot(), FormsModule, ReactiveFormsModule], + declarations: [BitstreamRequestACopyPageComponent], + providers: [ + {provide: Location, useValue: location}, + {provide: ActivatedRoute, useValue: activatedRoute}, + {provide: Router, useValue: router}, + {provide: AuthorizationDataService, useValue: authorizationService}, + {provide: AuthService, useValue: authService}, + {provide: ItemRequestDataService, useValue: itemRequestDataService}, + {provide: NotificationsService, useValue: notificationsService}, + {provide: DSONameService, useValue: new DSONameServiceMock()}, + {provide: BitstreamDataService, useValue: bitstreamDataService}, + ] + }) + .compileComponents(); + } + + describe('init', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should init the comp', () => { + expect(component).toBeTruthy(); + }); + }); + + describe('should show a form to request a copy', () => { + describe('when the user is not logged in', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('show the form with no values filled in based on the user', () => { + expect(component.name.value).toEqual(''); + expect(component.email.value).toEqual(''); + expect(component.allfiles.value).toEqual('false'); + expect(component.message.value).toEqual(''); + }); + }); + + describe('when the user is logged in', () => { + beforeEach(waitForAsync(() => { + init(); + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('show the form with values filled in based on the user', () => { + fixture.detectChanges(); + expect(component.name.value).toEqual(eperson.name); + expect(component.email.value).toEqual(eperson.email); + expect(component.allfiles.value).toEqual('false'); + expect(component.message.value).toEqual(''); + }); + }); + describe('when no bitstream was provided', () => { + beforeEach(waitForAsync(() => { + init(); + activatedRoute = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject( + item + ) + }), + queryParams: observableOf({ + }) + }; + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should set the all files value to true and disable the false value', () => { + expect(component.name.value).toEqual(''); + expect(component.email.value).toEqual(''); + expect(component.allfiles.value).toEqual('true'); + expect(component.message.value).toEqual(''); + + const allFilesFalse = fixture.debugElement.query(By.css('#allfiles-false')).nativeElement; + expect(allFilesFalse.getAttribute('disabled')).toBeTruthy(); + + }); + }); + describe('when the user has authorization to download the file', () => { + beforeEach(waitForAsync(() => { + init(); + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should show an alert indicating the user can download the file', () => { + const alert = fixture.debugElement.query(By.css('.alert')).nativeElement; + expect(alert.innerHTML).toContain('bitstream-request-a-copy.alert.canDownload'); + }); + }); + }); + + describe('onSubmit', () => { + describe('onSuccess', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should take the current form information and submit it', () => { + component.name.patchValue('User Name'); + component.email.patchValue('user@name.org'); + component.allfiles.patchValue('false'); + component.message.patchValue('I would like to request a copy'); + + component.onSubmit(); + const itemRequest = Object.assign(new ItemRequest(), + { + itemId: item.uuid, + bitstreamId: bitstream.uuid, + allfiles: 'false', + requestEmail: 'user@name.org', + requestName: 'User Name', + requestMessage: 'I would like to request a copy' + }); + + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(notificationsService.success).toHaveBeenCalled(); + expect(location.back).toHaveBeenCalled(); + }); + }); + + describe('onFail', () => { + beforeEach(waitForAsync(() => { + init(); + (itemRequestDataService.requestACopy as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should take the current form information and submit it', () => { + component.name.patchValue('User Name'); + component.email.patchValue('user@name.org'); + component.allfiles.patchValue('false'); + component.message.patchValue('I would like to request a copy'); + + component.onSubmit(); + const itemRequest = Object.assign(new ItemRequest(), + { + itemId: item.uuid, + bitstreamId: bitstream.uuid, + allfiles: 'false', + requestEmail: 'user@name.org', + requestName: 'User Name', + requestMessage: 'I would like to request a copy' + }); + + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(notificationsService.error).toHaveBeenCalled(); + expect(location.back).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts new file mode 100644 index 0000000000..3b9bc38aab --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts @@ -0,0 +1,213 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { filter, map, switchMap, take } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { AuthService } from '../../core/auth/auth.service'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { getBitstreamDownloadRoute, getForbiddenRoute } from '../../app-routing-paths'; +import { TranslateService } from '@ngx-translate/core'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Item } from '../../core/shared/item.model'; +import { NotificationsService } from '../notifications/notifications.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Location } from '@angular/common'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; + +@Component({ + selector: 'ds-bitstream-request-a-copy-page', + templateUrl: './bitstream-request-a-copy-page.component.html' +}) +/** + * Page component for requesting a copy for a bitstream + */ +export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { + + item$: Observable; + + canDownload$: Observable; + private subs: Subscription[] = []; + requestCopyForm: FormGroup; + + item: Item; + itemName: string; + + bitstream$: Observable; + bitstream: Bitstream; + bitstreamName: string; + + constructor(private location: Location, + private translateService: TranslateService, + private route: ActivatedRoute, + protected router: Router, + private authorizationService: AuthorizationDataService, + private auth: AuthService, + private formBuilder: FormBuilder, + private itemRequestDataService: ItemRequestDataService, + private notificationsService: NotificationsService, + private dsoNameService: DSONameService, + private bitstreamService: BitstreamDataService, + ) { + } + + ngOnInit(): void { + this.requestCopyForm = this.formBuilder.group({ + name: new FormControl('', { + validators: [Validators.required], + }), + email: new FormControl('', { + validators: [Validators.required, + Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$')] + }), + allfiles: new FormControl(''), + message: new FormControl(''), + }); + + + this.item$ = this.route.data.pipe( + map((data) => data.dso), + getFirstSucceededRemoteDataPayload() + ); + + this.subs.push(this.item$.subscribe((item) => { + this.item = item; + this.itemName = this.dsoNameService.getName(item); + })); + + this.bitstream$ = this.route.queryParams.pipe( + filter((params) => hasValue(params) && hasValue(params.bitstream)), + switchMap((params) => this.bitstreamService.findById(params.bitstream)), + getFirstSucceededRemoteDataPayload() + ); + + this.subs.push(this.bitstream$.subscribe((bitstream) => { + this.bitstream = bitstream; + this.bitstreamName = this.dsoNameService.getName(bitstream); + })); + + this.canDownload$ = this.bitstream$.pipe( + switchMap((bitstream) => this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined)) + ); + const canRequestCopy$ = this.bitstream$.pipe( + switchMap((bitstream) => this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(bitstream) ? bitstream.self : undefined)), + ); + + this.subs.push(observableCombineLatest([this.canDownload$, canRequestCopy$]).subscribe(([canDownload, canRequestCopy]) => { + if (!canDownload && !canRequestCopy) { + this.router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true}); + } + })); + this.initValues(); + } + + get name() { + return this.requestCopyForm.get('name'); + } + + get email() { + return this.requestCopyForm.get('email'); + } + + get message() { + return this.requestCopyForm.get('message'); + } + + get allfiles() { + return this.requestCopyForm.get('allfiles'); + } + + /** + * Initialise the form values based on the current user. + */ + private initValues() { + this.getCurrentUser().pipe(take(1)).subscribe((user) => { + this.requestCopyForm.patchValue({allfiles: 'true'}); + if (hasValue(user)) { + this.requestCopyForm.patchValue({name: user.name, email: user.email}); + } + }); + this.bitstream$.pipe(take(1)).subscribe((bitstream) => { + this.requestCopyForm.patchValue({allfiles: 'false'}); + }); + } + + /** + * Retrieve the current user + */ + private getCurrentUser(): Observable { + return this.auth.isAuthenticated().pipe( + switchMap((authenticated) => { + if (authenticated) { + return this.auth.getAuthenticatedUserFromStore(); + } else { + return observableOf(undefined); + } + }) + ); + + } + + /** + * Submit the the form values as an item request to the server. + * When the submission is successful, the user will be redirected to the item page and a success notification will be shown. + * When the submission fails, the user will stay on the page and an error notification will be shown + */ + onSubmit() { + const itemRequest = new ItemRequest(); + if (hasValue(this.bitstream)) { + itemRequest.bitstreamId = this.bitstream.uuid; + } + itemRequest.itemId = this.item.uuid; + itemRequest.allfiles = this.allfiles.value; + itemRequest.requestEmail = this.email.value; + itemRequest.requestName = this.name.value; + itemRequest.requestMessage = this.message.value; + + this.itemRequestDataService.requestACopy(itemRequest).pipe( + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('bitstream-request-a-copy.submit.success')); + this.navigateBack(); + } else { + this.notificationsService.error(this.translateService.get('bitstream-request-a-copy.submit.error')); + } + }); + } + + ngOnDestroy(): void { + if (hasValue(this.subs)) { + this.subs.forEach((sub) => { + if (hasValue(sub)) { + sub.unsubscribe(); + } + }); + } + } + + /** + * Navigates back to the user's previous location + */ + navigateBack() { + this.location.back(); + } + + getItemPath() { + return [getItemPageRoute(this.item)]; + } + + /** + * Retrieves the link to the bistream download page + */ + getBitstreamLink() { + return [getBitstreamDownloadRoute(this.bitstream)]; + } +} diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index 497502d586..0155c40b0a 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -1,4 +1,5 @@ - + + diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index 6f7f50e585..61e9ecb4aa 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -1,62 +1,145 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { FileDownloadLinkComponent } from './file-download-link.component'; -import { AuthService } from '../../core/auth/auth.service'; -import { FileService } from '../../core/shared/file.service'; -import { of as observableOf } from 'rxjs'; import { Bitstream } from '../../core/shared/bitstream.model'; import { By } from '@angular/platform-browser'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { getBitstreamModuleRoute } from '../../app-routing-paths'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { Item } from '../../core/shared/item.model'; +import { getItemModuleRoute } from '../../item-page/item-page-routing-paths'; +import { RouterLinkDirectiveStub } from '../testing/router-link-directive.stub'; describe('FileDownloadLinkComponent', () => { let component: FileDownloadLinkComponent; let fixture: ComponentFixture; - let authService: AuthService; - let fileService: FileService; + let scheduler; + let authorizationService: AuthorizationDataService; + let bitstream: Bitstream; + let item: Item; function init() { - authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: cold('-a', {a: true}) }); - fileService = jasmine.createSpyObj('fileService', ['downloadFile']); bitstream = Object.assign(new Bitstream(), { uuid: 'bitstreamUuid', + _links: { + self: {href: 'obj-selflink'} + } + }); + item = Object.assign(new Item(), { + uuid: 'itemUuid', + _links: { + self: {href: 'obj-selflink'} + } }); } - beforeEach(waitForAsync(() => { - init(); + function initTestbed() { TestBed.configureTestingModule({ - declarations: [FileDownloadLinkComponent], + declarations: [FileDownloadLinkComponent, RouterLinkDirectiveStub], providers: [ - { provide: AuthService, useValue: authService }, - { provide: FileService, useValue: fileService }, + {provide: AuthorizationDataService, useValue: authorizationService}, ] }) .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(FileDownloadLinkComponent); - component = fixture.componentInstance; - component.bitstream = bitstream; - fixture.detectChanges(); - }); + } describe('init', () => { - describe('getBitstreamPath', () => { - it('should set the bitstreamPath based on the input bitstream', () => { - expect(component.bitstreamPath).toEqual(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + describe('when the user has download rights', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + initTestbed(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: {} }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: true})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')); + expect(lock).toBeNull(); + }); + }); + describe('when the user has no download rights but has the right to request a copy', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + (authorizationService.isAuthorized as jasmine.Spy).and.callFake((featureId, object) => { + if (featureId === FeatureID.CanDownload) { + return cold('-a', {a: false}); + } + return cold('-a', {a: true}); + }); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.item = item; + component.bitstream = bitstream; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString(), queryParams: { bitstream: bitstream.uuid } }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: false})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; + expect(lock).toBeTruthy(); + }); + }); + describe('when the user has no download rights and no request a copy rights', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(cold('-a', {a: false})); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: {} }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: false})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; + expect(lock).toBeTruthy(); + }); }); }); - - it('should init the component', () => { - const link = fixture.debugElement.query(By.css('a')).nativeElement; - expect(link.href).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); - }); - }); }); diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index b415e1e701..a79a71b634 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -1,6 +1,12 @@ import { Component, Input, OnInit } from '@angular/core'; import { Bitstream } from '../../core/shared/bitstream.model'; -import { getBitstreamDownloadRoute } from '../../app-routing-paths'; +import { getBitstreamDownloadRoute, getBitstreamRequestACopyRoute } from '../../app-routing-paths'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { map } from 'rxjs/operators'; +import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { Item } from '../../core/shared/item.model'; @Component({ selector: 'ds-file-download-link', @@ -19,6 +25,8 @@ export class FileDownloadLinkComponent implements OnInit { */ @Input() bitstream: Bitstream; + @Input() item: Item; + /** * Additional css classes to apply to link */ @@ -29,13 +37,44 @@ export class FileDownloadLinkComponent implements OnInit { */ @Input() isBlank = false; - bitstreamPath: string; + @Input() enableRequestACopy = true; + + bitstreamPath$: Observable<{ + routerLink: string, + queryParams: any, + }>; + + canDownload$: Observable; + + constructor( + private authorizationService: AuthorizationDataService, + ) { + } ngOnInit() { - this.bitstreamPath = this.getBitstreamPath(); + if (this.enableRequestACopy) { + this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + const canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + this.bitstreamPath$ = observableCombineLatest([this.canDownload$, canRequestACopy$]).pipe( + map(([canDownload, canRequestACopy]) => this.getBitstreamPath(canDownload, canRequestACopy)) + ); + } else { + this.bitstreamPath$ = observableOf(this.getBitstreamDownloadPath()); + this.canDownload$ = observableOf(true); + } } - getBitstreamPath() { - return getBitstreamDownloadRoute(this.bitstream); + getBitstreamPath(canDownload: boolean, canRequestACopy: boolean) { + if (!canDownload && canRequestACopy && hasValue(this.item)) { + return getBitstreamRequestACopyRoute(this.item, this.bitstream); + } + return this.getBitstreamDownloadPath(); + } + + getBitstreamDownloadPath() { + return { + routerLink: getBitstreamDownloadRoute(this.bitstream), + queryParams: {} + }; } } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 35a5af4c56..5ab826d9bf 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -237,6 +237,7 @@ import { SearchNavbarComponent } from '../search-navbar/search-navbar.component' import { ItemVersionsSummaryModalComponent } from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; import { ItemVersionsDeleteModalComponent } from './item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component'; import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component'; +import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; /** * Declaration needed to make sure all decorator functions are called in time @@ -435,6 +436,7 @@ const COMPONENTS = [ GroupSearchBoxComponent, FileDownloadLinkComponent, BitstreamDownloadPageComponent, + BitstreamRequestACopyPageComponent, CollectionDropdownComponent, ExportMetadataSelectorComponent, ConfirmationModalComponent, @@ -517,6 +519,7 @@ const ENTRY_COMPONENTS = [ CollectionDropdownComponent, FileDownloadLinkComponent, BitstreamDownloadPageComponent, + BitstreamRequestACopyPageComponent, CurationFormComponent, ExportMetadataSelectorComponent, ConfirmationModalComponent, diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.html b/src/app/submission/sections/upload/file/section-upload-file.component.html index bac71df1a7..259418c22c 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.html +++ b/src/app/submission/sections/upload/file/section-upload-file.component.html @@ -10,7 +10,7 @@
- +