Merge pull request #2248 from 4Science/feature/CST-5729

Implement Signposting on angular side
This commit is contained in:
Tim Donohue
2023-06-09 16:03:10 -05:00
committed by GitHub
13 changed files with 474 additions and 36 deletions

View File

@@ -26,7 +26,6 @@ import * as ejs from 'ejs';
import * as compression from 'compression'; import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip'; import * as expressStaticGzip from 'express-static-gzip';
/* eslint-enable import/no-namespace */ /* eslint-enable import/no-namespace */
import axios from 'axios'; import axios from 'axios';
import LRU from 'lru-cache'; import LRU from 'lru-cache';
import isbot from 'isbot'; import isbot from 'isbot';
@@ -34,7 +33,7 @@ import { createCertificate } from 'pem';
import { createServer } from 'https'; import { createServer } from 'https';
import { json } from 'body-parser'; import { json } from 'body-parser';
import { existsSync, readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { enableProdMode } from '@angular/core'; import { enableProdMode } from '@angular/core';
@@ -180,6 +179,15 @@ export function app() {
changeOrigin: true changeOrigin: true
})); }));
/**
* Proxy the linksets
*/
router.use('/signposting**', createProxyMiddleware({
target: `${environment.rest.baseUrl}`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true
}));
/** /**
* Checks if the rateLimiter property is present * Checks if the rateLimiter property is present
* When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled. * When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled.

View File

@@ -11,6 +11,9 @@ import { ActivatedRoute, Router } from '@angular/router';
import { getForbiddenRoute } from '../../app-routing-paths'; import { getForbiddenRoute } from '../../app-routing-paths';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { ServerResponseService } from '../../core/services/server-response.service';
import { PLATFORM_ID } from '@angular/core';
describe('BitstreamDownloadPageComponent', () => { describe('BitstreamDownloadPageComponent', () => {
let component: BitstreamDownloadPageComponent; let component: BitstreamDownloadPageComponent;
@@ -24,6 +27,20 @@ describe('BitstreamDownloadPageComponent', () => {
let router; let router;
let bitstream: Bitstream; let bitstream: Bitstream;
let serverResponseService: jasmine.SpyObj<ServerResponseService>;
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
const mocklink = {
href: 'http://test.org',
rel: 'test',
type: 'test'
};
const mocklink2 = {
href: 'http://test2.org',
rel: 'test',
type: 'test'
};
function init() { function init() {
authService = jasmine.createSpyObj('authService', { authService = jasmine.createSpyObj('authService', {
@@ -44,8 +61,8 @@ describe('BitstreamDownloadPageComponent', () => {
bitstream = Object.assign(new Bitstream(), { bitstream = Object.assign(new Bitstream(), {
uuid: 'bitstreamUuid', uuid: 'bitstreamUuid',
_links: { _links: {
content: {href: 'bitstream-content-link'}, content: { href: 'bitstream-content-link' },
self: {href: 'bitstream-self-link'}, self: { href: 'bitstream-self-link' },
} }
}); });
@@ -54,10 +71,21 @@ describe('BitstreamDownloadPageComponent', () => {
bitstream: createSuccessfulRemoteDataObject( bitstream: createSuccessfulRemoteDataObject(
bitstream bitstream
) )
}),
params: observableOf({
id: 'testid'
}) })
}; };
router = jasmine.createSpyObj('router', ['navigateByUrl']); router = jasmine.createSpyObj('router', ['navigateByUrl']);
serverResponseService = jasmine.createSpyObj('ServerResponseService', {
setHeader: jasmine.createSpy('setHeader'),
});
signpostingDataService = jasmine.createSpyObj('SignpostingDataService', {
getLinks: observableOf([mocklink, mocklink2])
});
} }
function initTestbed() { function initTestbed() {
@@ -65,12 +93,15 @@ describe('BitstreamDownloadPageComponent', () => {
imports: [CommonModule, TranslateModule.forRoot()], imports: [CommonModule, TranslateModule.forRoot()],
declarations: [BitstreamDownloadPageComponent], declarations: [BitstreamDownloadPageComponent],
providers: [ providers: [
{provide: ActivatedRoute, useValue: activatedRoute}, { provide: ActivatedRoute, useValue: activatedRoute },
{provide: Router, useValue: router}, { provide: Router, useValue: router },
{provide: AuthorizationDataService, useValue: authorizationService}, { provide: AuthorizationDataService, useValue: authorizationService },
{provide: AuthService, useValue: authService}, { provide: AuthService, useValue: authService },
{provide: FileService, useValue: fileService}, { provide: FileService, useValue: fileService },
{provide: HardRedirectService, useValue: hardRedirectService}, { provide: HardRedirectService, useValue: hardRedirectService },
{ provide: ServerResponseService, useValue: serverResponseService },
{ provide: SignpostingDataService, useValue: signpostingDataService },
{ provide: PLATFORM_ID, useValue: 'server' }
] ]
}) })
.compileComponents(); .compileComponents();
@@ -107,6 +138,9 @@ describe('BitstreamDownloadPageComponent', () => {
it('should redirect to the content link', () => { it('should redirect to the content link', () => {
expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link'); expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link');
}); });
it('should add the signposting links', () => {
expect(serverResponseService.setHeader).toHaveBeenCalled();
});
}); });
describe('when the user is authorized and logged in', () => { describe('when the user is authorized and logged in', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
@@ -134,7 +168,7 @@ describe('BitstreamDownloadPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should navigate to the forbidden route', () => { it('should navigate to the forbidden route', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), {skipLocationChange: true}); expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true });
}); });
}); });
describe('when the user is not authorized and not logged in', () => { describe('when the user is not authorized and not logged in', () => {

View File

@@ -1,8 +1,8 @@
import { Component, OnInit } from '@angular/core'; import { Component, Inject, OnInit, PLATFORM_ID } from '@angular/core';
import { filter, map, switchMap, take } from 'rxjs/operators'; import { filter, map, switchMap, take } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { getRemoteDataPayload} from '../../core/shared/operators'; import { getRemoteDataPayload } from '../../core/shared/operators';
import { Bitstream } from '../../core/shared/bitstream.model'; import { Bitstream } from '../../core/shared/bitstream.model';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
@@ -13,8 +13,11 @@ import { HardRedirectService } from '../../core/services/hard-redirect.service';
import { getForbiddenRoute } from '../../app-routing-paths'; import { getForbiddenRoute } from '../../app-routing-paths';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { redirectOn4xx } from '../../core/shared/authorized.operators';
import { Location } from '@angular/common'; import { isPlatformServer, Location } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { ServerResponseService } from '../../core/services/server-response.service';
import { SignpostingLink } from '../../core/data/signposting-links.model';
@Component({ @Component({
selector: 'ds-bitstream-download-page', selector: 'ds-bitstream-download-page',
@@ -28,7 +31,6 @@ export class BitstreamDownloadPageComponent implements OnInit {
bitstream$: Observable<Bitstream>; bitstream$: Observable<Bitstream>;
bitstreamRD$: Observable<RemoteData<Bitstream>>; bitstreamRD$: Observable<RemoteData<Bitstream>>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
protected router: Router, protected router: Router,
@@ -38,8 +40,11 @@ export class BitstreamDownloadPageComponent implements OnInit {
private hardRedirectService: HardRedirectService, private hardRedirectService: HardRedirectService,
private location: Location, private location: Location,
public dsoNameService: DSONameService, public dsoNameService: DSONameService,
private signpostingDataService: SignpostingDataService,
private responseService: ServerResponseService,
@Inject(PLATFORM_ID) protected platformId: string
) { ) {
this.initPageLinks();
} }
back(): void { back(): void {
@@ -89,4 +94,26 @@ export class BitstreamDownloadPageComponent implements OnInit {
} }
}); });
} }
/**
* Create page links if any are retrieved by signposting endpoint
*
* @private
*/
private initPageLinks(): void {
if (isPlatformServer(this.platformId)) {
this.route.params.subscribe(params => {
this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => {
let links = '';
signpostingLinks.forEach((link: SignpostingLink) => {
links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' ');
links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}" ; type="${link.type}" `;
});
this.responseService.setHeader('Link', links);
});
});
}
}
} }

View File

@@ -0,0 +1,97 @@
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { SignpostingDataService } from './signposting-data.service';
import { DspaceRestService } from '../dspace-rest/dspace-rest.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { of } from 'rxjs';
import { SignpostingLink } from './signposting-links.model';
describe('SignpostingDataService', () => {
let service: SignpostingDataService;
let restServiceSpy: jasmine.SpyObj<DspaceRestService>;
let halServiceSpy: jasmine.SpyObj<HALEndpointService>;
const mocklink = {
href: 'http://test.org',
rel: 'test',
type: 'test'
};
const mocklink2 = {
href: 'http://test2.org',
rel: 'test',
type: 'test'
};
const mockResponse: any = {
statusCode: 200,
payload: [mocklink, mocklink2]
};
const mockErrResponse: any = {
statusCode: 500
};
beforeEach(() => {
const restSpy = jasmine.createSpyObj('DspaceRestService', ['get', 'getWithHeaders']);
const halSpy = jasmine.createSpyObj('HALEndpointService', ['getRootHref']);
TestBed.configureTestingModule({
providers: [
SignpostingDataService,
{ provide: DspaceRestService, useValue: restSpy },
{ provide: HALEndpointService, useValue: halSpy }
]
});
service = TestBed.inject(SignpostingDataService);
restServiceSpy = TestBed.inject(DspaceRestService) as jasmine.SpyObj<DspaceRestService>;
halServiceSpy = TestBed.inject(HALEndpointService) as jasmine.SpyObj<HALEndpointService>;
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should return signposting links', fakeAsync(() => {
const uuid = '123';
const baseUrl = 'http://localhost:8080';
halServiceSpy.getRootHref.and.returnValue(`${baseUrl}/api`);
restServiceSpy.get.and.returnValue(of(mockResponse));
let result: SignpostingLink[];
const expectedResult: SignpostingLink[] = [mocklink, mocklink2];
service.getLinks(uuid).subscribe((links) => {
result = links;
});
tick();
expect(result).toEqual(expectedResult);
expect(halServiceSpy.getRootHref).toHaveBeenCalled();
expect(restServiceSpy.get).toHaveBeenCalledWith(`${baseUrl}/signposting/links/${uuid}`);
}));
it('should handle error and return an empty array', fakeAsync(() => {
const uuid = '123';
const baseUrl = 'http://localhost:8080';
halServiceSpy.getRootHref.and.returnValue(`${baseUrl}/api`);
restServiceSpy.get.and.returnValue(of(mockErrResponse));
let result: any;
service.getLinks(uuid).subscribe((data) => {
result = data;
});
tick();
expect(result).toEqual([]);
expect(halServiceSpy.getRootHref).toHaveBeenCalled();
expect(restServiceSpy.get).toHaveBeenCalledWith(`${baseUrl}/signposting/links/${uuid}`);
}));
});

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
import { catchError, map } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
import { DspaceRestService } from '../dspace-rest/dspace-rest.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { SignpostingLink } from './signposting-links.model';
/**
* Service responsible for handling requests related to the Signposting endpoint
*/
@Injectable({
providedIn: 'root'
})
export class SignpostingDataService {
constructor(private restService: DspaceRestService, protected halService: HALEndpointService) {
}
/**
* Retrieve the list of signposting links related to the given resource's id
*
* @param uuid
*/
getLinks(uuid: string): Observable<SignpostingLink[]> {
const baseUrl = this.halService.getRootHref().replace('/api', '');
return this.restService.get(`${baseUrl}/signposting/links/${uuid}`).pipe(
catchError((err) => {
return observableOf([]);
}),
map((res: RawRestResponse) => res.statusCode === 200 ? res.payload as SignpostingLink[] : [])
);
}
}

View File

@@ -0,0 +1,8 @@
/**
* Represents the link object received by the signposting endpoint
*/
export interface SignpostingLink {
href?: string,
rel?: string,
type?: string
}

View File

@@ -46,6 +46,7 @@ export class ServerHardRedirectService extends HardRedirectService {
} }
console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`); console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`);
this.res.redirect(status, url); this.res.redirect(status, url);
this.res.end(); this.res.end();
// I haven't found a way to correctly stop Angular rendering. // I haven't found a way to correctly stop Angular rendering.

View File

@@ -1,7 +1,11 @@
import { RESPONSE } from '@nguniversal/express-engine/tokens'; import { RESPONSE } from '@nguniversal/express-engine/tokens';
import { Inject, Injectable, Optional } from '@angular/core'; import { Inject, Injectable, Optional } from '@angular/core';
import { Response } from 'express'; import { Response } from 'express';
/**
* Service responsible to provide method to manage the response object
*/
@Injectable() @Injectable()
export class ServerResponseService { export class ServerResponseService {
private response: Response; private response: Response;
@@ -10,6 +14,12 @@ export class ServerResponseService {
this.response = response; this.response = response;
} }
/**
* Set a status code to response
*
* @param code
* @param message
*/
setStatus(code: number, message?: string): this { setStatus(code: number, message?: string): this {
if (this.response) { if (this.response) {
this.response.statusCode = code; this.response.statusCode = code;
@@ -20,19 +30,51 @@ export class ServerResponseService {
return this; return this;
} }
/**
* Set Unauthorized status
*
* @param message
*/
setUnauthorized(message = 'Unauthorized'): this { setUnauthorized(message = 'Unauthorized'): this {
return this.setStatus(401, message); return this.setStatus(401, message);
} }
/**
* Set Forbidden status
*
* @param message
*/
setForbidden(message = 'Forbidden'): this { setForbidden(message = 'Forbidden'): this {
return this.setStatus(403, message); return this.setStatus(403, message);
} }
/**
* Set Not found status
*
* @param message
*/
setNotFound(message = 'Not found'): this { setNotFound(message = 'Not found'): this {
return this.setStatus(404, message); return this.setStatus(404, message);
} }
/**
* Set Internal Server Error status
*
* @param message
*/
setInternalServerError(message = 'Internal Server Error'): this { setInternalServerError(message = 'Internal Server Error'): this {
return this.setStatus(500, message); return this.setStatus(500, message);
} }
/**
* Set a response's header
*
* @param header
* @param content
*/
setHeader(header: string, content: string) {
if (this.response) {
this.response.setHeader(header, content);
}
}
} }

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/cor
import { ItemDataService } from '../../core/data/item-data.service'; import { ItemDataService } from '../../core/data/item-data.service';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core';
import { TruncatePipe } from '../../shared/utils/truncate.pipe'; import { TruncatePipe } from '../../shared/utils/truncate.pipe';
import { FullItemPageComponent } from './full-item-page.component'; import { FullItemPageComponent } from './full-item-page.component';
import { MetadataService } from '../../core/metadata/metadata.service'; import { MetadataService } from '../../core/metadata/metadata.service';
@@ -20,6 +20,9 @@ import { createPaginatedList } from '../../shared/testing/utils.test';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { createRelationshipsObservable } from '../simple/item-types/shared/item.component.spec'; import { createRelationshipsObservable } from '../simple/item-types/shared/item.component.spec';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { ServerResponseService } from '../../core/services/server-response.service';
import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { LinkHeadService } from '../../core/services/link-head.service';
const mockItem: Item = Object.assign(new Item(), { const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
@@ -55,8 +58,21 @@ describe('FullItemPageComponent', () => {
let routeStub: ActivatedRouteStub; let routeStub: ActivatedRouteStub;
let routeData; let routeData;
let authorizationDataService: AuthorizationDataService; let authorizationDataService: AuthorizationDataService;
let serverResponseService: jasmine.SpyObj<ServerResponseService>;
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
let linkHeadService: jasmine.SpyObj<LinkHeadService>;
const mocklink = {
href: 'http://test.org',
rel: 'test',
type: 'test'
};
const mocklink2 = {
href: 'http://test2.org',
rel: 'test',
type: 'test'
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
authService = jasmine.createSpyObj('authService', { authService = jasmine.createSpyObj('authService', {
@@ -76,6 +92,19 @@ describe('FullItemPageComponent', () => {
isAuthorized: observableOf(false), isAuthorized: observableOf(false),
}); });
serverResponseService = jasmine.createSpyObj('ServerResponseService', {
setHeader: jasmine.createSpy('setHeader'),
});
signpostingDataService = jasmine.createSpyObj('SignpostingDataService', {
getLinks: observableOf([mocklink, mocklink2]),
});
linkHeadService = jasmine.createSpyObj('LinkHeadService', {
addTag: jasmine.createSpy('setHeader'),
removeTag: jasmine.createSpy('removeTag'),
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({ imports: [TranslateModule.forRoot({
loader: { loader: {
@@ -90,8 +119,11 @@ describe('FullItemPageComponent', () => {
{ provide: MetadataService, useValue: metadataServiceStub }, { provide: MetadataService, useValue: metadataServiceStub },
{ provide: AuthService, useValue: authService }, { provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationDataService }, { provide: AuthorizationDataService, useValue: authorizationDataService },
{ provide: ServerResponseService, useValue: serverResponseService },
{ provide: SignpostingDataService, useValue: signpostingDataService },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: PLATFORM_ID, useValue: 'server' }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(FullItemPageComponent, { }).overrideComponent(FullItemPageComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default } set: { changeDetection: ChangeDetectionStrategy.Default }
@@ -143,6 +175,11 @@ describe('FullItemPageComponent', () => {
const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); const objectLoader = fixture.debugElement.query(By.css('.full-item-info'));
expect(objectLoader.nativeElement).not.toBeNull(); expect(objectLoader.nativeElement).not.toBeNull();
}); });
it('should add the signposting links', () => {
expect(serverResponseService.setHeader).toHaveBeenCalled();
expect(linkHeadService.addTag).toHaveBeenCalledTimes(2);
});
}); });
describe('when the item is withdrawn and the user is not an admin', () => { describe('when the item is withdrawn and the user is not an admin', () => {
beforeEach(() => { beforeEach(() => {
@@ -167,6 +204,11 @@ describe('FullItemPageComponent', () => {
const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); const objectLoader = fixture.debugElement.query(By.css('.full-item-info'));
expect(objectLoader).not.toBeNull(); expect(objectLoader).not.toBeNull();
}); });
it('should add the signposting links', () => {
expect(serverResponseService.setHeader).toHaveBeenCalled();
expect(linkHeadService.addTag).toHaveBeenCalledTimes(2);
});
}); });
describe('when the item is not withdrawn and the user is not an admin', () => { describe('when the item is not withdrawn and the user is not an admin', () => {
@@ -179,5 +221,10 @@ describe('FullItemPageComponent', () => {
const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); const objectLoader = fixture.debugElement.query(By.css('.full-item-info'));
expect(objectLoader).not.toBeNull(); expect(objectLoader).not.toBeNull();
}); });
it('should add the signposting links', () => {
expect(serverResponseService.setHeader).toHaveBeenCalled();
expect(linkHeadService.addTag).toHaveBeenCalledTimes(2);
});
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { filter, map } from 'rxjs/operators'; import { filter, map } from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { ActivatedRoute, Data, Router } from '@angular/router'; import { ActivatedRoute, Data, Router } from '@angular/router';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
@@ -16,7 +16,9 @@ import { hasValue } from '../../shared/empty.util';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ServerResponseService } from '../../core/services/server-response.service';
import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { LinkHeadService } from '../../core/services/link-head.service';
/** /**
* This component renders a full item page. * This component renders a full item page.
@@ -43,13 +45,19 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit,
subs = []; subs = [];
constructor(protected route: ActivatedRoute, constructor(
router: Router, protected route: ActivatedRoute,
items: ItemDataService, protected router: Router,
authService: AuthService, protected items: ItemDataService,
authorizationService: AuthorizationDataService, protected authService: AuthService,
private _location: Location) { protected authorizationService: AuthorizationDataService,
super(route, router, items, authService, authorizationService); protected _location: Location,
protected responseService: ServerResponseService,
protected signpostingDataService: SignpostingDataService,
protected linkHeadService: LinkHeadService,
@Inject(PLATFORM_ID) protected platformId: string,
) {
super(route, router, items, authService, authorizationService, responseService, signpostingDataService, linkHeadService, platformId);
} }
/*** AoT inheritance fix, will hopefully be resolved in the near future **/ /*** AoT inheritance fix, will hopefully be resolved in the near future **/

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { ItemDataService } from '../../core/data/item-data.service'; import { ItemDataService } from '../../core/data/item-data.service';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core';
import { ItemPageComponent } from './item-page.component'; import { ItemPageComponent } from './item-page.component';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
@@ -22,6 +22,10 @@ import {
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ServerResponseService } from '../../core/services/server-response.service';
import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service';
import { SignpostingLink } from '../../core/data/signposting-links.model';
const mockItem: Item = Object.assign(new Item(), { const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
@@ -36,11 +40,28 @@ const mockWithdrawnItem: Item = Object.assign(new Item(), {
isWithdrawn: true isWithdrawn: true
}); });
const mocklink = {
href: 'http://test.org',
rel: 'rel1',
type: 'type1'
};
const mocklink2 = {
href: 'http://test2.org',
rel: 'rel2',
type: undefined
};
const mockSignpostingLinks: SignpostingLink[] = [mocklink, mocklink2];
describe('ItemPageComponent', () => { describe('ItemPageComponent', () => {
let comp: ItemPageComponent; let comp: ItemPageComponent;
let fixture: ComponentFixture<ItemPageComponent>; let fixture: ComponentFixture<ItemPageComponent>;
let authService: AuthService; let authService: AuthService;
let authorizationDataService: AuthorizationDataService; let authorizationDataService: AuthorizationDataService;
let serverResponseService: jasmine.SpyObj<ServerResponseService>;
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
let linkHeadService: jasmine.SpyObj<LinkHeadService>;
const mockMetadataService = { const mockMetadataService = {
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */ /* eslint-disable no-empty,@typescript-eslint/no-empty-function */
@@ -60,6 +81,18 @@ describe('ItemPageComponent', () => {
authorizationDataService = jasmine.createSpyObj('authorizationDataService', { authorizationDataService = jasmine.createSpyObj('authorizationDataService', {
isAuthorized: observableOf(false), isAuthorized: observableOf(false),
}); });
serverResponseService = jasmine.createSpyObj('ServerResponseService', {
setHeader: jasmine.createSpy('setHeader'),
});
signpostingDataService = jasmine.createSpyObj('SignpostingDataService', {
getLinks: observableOf([mocklink, mocklink2]),
});
linkHeadService = jasmine.createSpyObj('LinkHeadService', {
addTag: jasmine.createSpy('setHeader'),
removeTag: jasmine.createSpy('removeTag'),
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({ imports: [TranslateModule.forRoot({
@@ -76,6 +109,10 @@ describe('ItemPageComponent', () => {
{ provide: Router, useValue: {} }, { provide: Router, useValue: {} },
{ provide: AuthService, useValue: authService }, { provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationDataService }, { provide: AuthorizationDataService, useValue: authorizationDataService },
{ provide: ServerResponseService, useValue: serverResponseService },
{ provide: SignpostingDataService, useValue: signpostingDataService },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: PLATFORM_ID, useValue: 'server' },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -126,6 +163,33 @@ describe('ItemPageComponent', () => {
const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader'));
expect(objectLoader.nativeElement).toBeDefined(); expect(objectLoader.nativeElement).toBeDefined();
}); });
it('should add the signposting links', () => {
expect(serverResponseService.setHeader).toHaveBeenCalled();
expect(linkHeadService.addTag).toHaveBeenCalledTimes(2);
});
it('should add link tags correctly', () => {
expect(comp.signpostingLinks).toEqual([mocklink, mocklink2]);
// Check if linkHeadService.addTag() was called with the correct arguments
expect(linkHeadService.addTag).toHaveBeenCalledTimes(mockSignpostingLinks.length);
let expected: LinkDefinition = mockSignpostingLinks[0] as LinkDefinition;
expect(linkHeadService.addTag).toHaveBeenCalledWith(expected);
expected = {
href: 'http://test2.org',
rel: 'rel2'
};
expect(linkHeadService.addTag).toHaveBeenCalledWith(expected);
});
it('should set Link header on the server', () => {
expect(serverResponseService.setHeader).toHaveBeenCalledWith('Link', '<http://test.org> ; rel="rel1" ; type="type1" , <http://test2.org> ; rel="rel2" ');
});
}); });
describe('when the item is withdrawn and the user is not an admin', () => { describe('when the item is withdrawn and the user is not an admin', () => {
beforeEach(() => { beforeEach(() => {
@@ -150,6 +214,11 @@ describe('ItemPageComponent', () => {
const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader'));
expect(objectLoader.nativeElement).toBeDefined(); expect(objectLoader.nativeElement).toBeDefined();
}); });
it('should add the signposting links', () => {
expect(serverResponseService.setHeader).toHaveBeenCalled();
expect(linkHeadService.addTag).toHaveBeenCalledTimes(2);
});
}); });
describe('when the item is not withdrawn and the user is not an admin', () => { describe('when the item is not withdrawn and the user is not an admin', () => {
@@ -162,6 +231,11 @@ describe('ItemPageComponent', () => {
const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader'));
expect(objectLoader.nativeElement).toBeDefined(); expect(objectLoader.nativeElement).toBeDefined();
}); });
it('should add the signposting links', () => {
expect(serverResponseService.setHeader).toHaveBeenCalled();
expect(linkHeadService.addTag).toHaveBeenCalledTimes(2);
});
}); });
}); });

View File

@@ -1,8 +1,9 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { isPlatformServer } from '@angular/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import { ItemDataService } from '../../core/data/item-data.service'; import { ItemDataService } from '../../core/data/item-data.service';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
@@ -15,6 +16,11 @@ import { getItemPageRoute } from '../item-page-routing-paths';
import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { redirectOn4xx } from '../../core/shared/authorized.operators';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { ServerResponseService } from '../../core/services/server-response.service';
import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { SignpostingLink } from '../../core/data/signposting-links.model';
import { isNotEmpty } from '../../shared/empty.util';
import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service';
/** /**
* This component renders a simple item page. * This component renders a simple item page.
@@ -28,7 +34,7 @@ import { FeatureID } from '../../core/data/feature-authorization/feature-id';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeInOut] animations: [fadeInOut]
}) })
export class ItemPageComponent implements OnInit { export class ItemPageComponent implements OnInit, OnDestroy {
/** /**
* The item's id * The item's id
@@ -57,13 +63,23 @@ export class ItemPageComponent implements OnInit {
itemUrl: string; itemUrl: string;
/**
* Contains a list of SignpostingLink related to the item
*/
signpostingLinks: SignpostingLink[] = [];
constructor( constructor(
protected route: ActivatedRoute, protected route: ActivatedRoute,
private router: Router, protected router: Router,
private items: ItemDataService, protected items: ItemDataService,
private authService: AuthService, protected authService: AuthService,
private authorizationService: AuthorizationDataService protected authorizationService: AuthorizationDataService,
protected responseService: ServerResponseService,
protected signpostingDataService: SignpostingDataService,
protected linkHeadService: LinkHeadService,
@Inject(PLATFORM_ID) protected platformId: string
) { ) {
this.initPageLinks();
} }
/** /**
@@ -82,4 +98,42 @@ export class ItemPageComponent implements OnInit {
this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf);
} }
/**
* Create page links if any are retrieved by signposting endpoint
*
* @private
*/
private initPageLinks(): void {
this.route.params.subscribe(params => {
this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => {
let links = '';
this.signpostingLinks = signpostingLinks;
signpostingLinks.forEach((link: SignpostingLink) => {
links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' ');
let tag: LinkDefinition = {
href: link.href,
rel: link.rel
};
if (isNotEmpty(link.type)) {
tag = Object.assign(tag, {
type: link.type
});
}
this.linkHeadService.addTag(tag);
});
if (isPlatformServer(this.platformId)) {
this.responseService.setHeader('Link', links);
}
});
});
}
ngOnDestroy(): void {
this.signpostingLinks.forEach((link: SignpostingLink) => {
this.linkHeadService.removeTag(`href='${link.href}'`);
});
}
} }

View File

@@ -38,7 +38,7 @@ export class ServerInitService extends InitService {
protected metadata: MetadataService, protected metadata: MetadataService,
protected breadcrumbsService: BreadcrumbsService, protected breadcrumbsService: BreadcrumbsService,
protected themeService: ThemeService, protected themeService: ThemeService,
protected menuService: MenuService, protected menuService: MenuService
) { ) {
super( super(
store, store,