Merge remote-tracking branch 'upstream/main' into w2p-94390_replace-dso-page-edit-buttons-with-a-menu

This commit is contained in:
Yana De Pauw
2023-02-09 14:25:10 +01:00
54 changed files with 931 additions and 134 deletions

View File

@@ -11,6 +11,7 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import objectContaining = jasmine.objectContaining; import objectContaining = jasmine.objectContaining;
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../data/rest-request-method';
import { Observable, of as observableOf } from 'rxjs';
describe(`AuthRequestService`, () => { describe(`AuthRequestService`, () => {
let halService: HALEndpointService; let halService: HALEndpointService;
@@ -34,8 +35,8 @@ describe(`AuthRequestService`, () => {
super(hes, rs, rdbs); super(hes, rs, rdbs);
} }
protected createShortLivedTokenRequest(href: string): PostRequest { protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return new PostRequest(this.requestService.generateRequestId(), href); return observableOf(new PostRequest(this.requestService.generateRequestId(), href));
} }
} }

View File

@@ -100,14 +100,12 @@ export abstract class AuthRequestService {
); );
} }
/** /**
* Factory function to create the request object to send. This needs to be a POST client side and * Factory function to create the request object to send.
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* *
* @param href The href to send the request to * @param href The href to send the request to
* @protected * @protected
*/ */
protected abstract createShortLivedTokenRequest(href: string): GetRequest | PostRequest; protected abstract createShortLivedTokenRequest(href: string): Observable<PostRequest>;
/** /**
* Send a request to retrieve a short-lived token which provides download access of restricted files * Send a request to retrieve a short-lived token which provides download access of restricted files
@@ -117,7 +115,7 @@ export abstract class AuthRequestService {
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),
distinctUntilChanged(), distinctUntilChanged(),
map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()), map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()),
map((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)), switchMap((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)),
tap((request: RestRequest) => this.requestService.send(request)), tap((request: RestRequest) => this.requestService.send(request)),
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<ShortLivedToken>(request.uuid)), switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<ShortLivedToken>(request.uuid)),
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),

View File

@@ -1,6 +1,8 @@
import { AuthRequestService } from './auth-request.service'; import { AuthRequestService } from './auth-request.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { BrowserAuthRequestService } from './browser-auth-request.service'; import { BrowserAuthRequestService } from './browser-auth-request.service';
import { Observable } from 'rxjs';
import { PostRequest } from '../data/request.models';
describe(`BrowserAuthRequestService`, () => { describe(`BrowserAuthRequestService`, () => {
let href: string; let href: string;
@@ -16,14 +18,20 @@ describe(`BrowserAuthRequestService`, () => {
}); });
describe(`createShortLivedTokenRequest`, () => { describe(`createShortLivedTokenRequest`, () => {
it(`should return a PostRequest`, () => { it(`should return a PostRequest`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.constructor.name).toBe('PostRequest'); obs.subscribe((result: PostRequest) => {
expect(result.constructor.name).toBe('PostRequest');
done();
});
}); });
it(`should return a request with the given href`, () => { it(`should return a request with the given href`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.href).toBe(href) ; obs.subscribe((result: PostRequest) => {
expect(result.href).toBe(href);
done();
});
}); });
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { PostRequest } from '../data/request.models';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Observable, of as observableOf } from 'rxjs';
/** /**
* Client side version of the service to send authentication requests * Client side version of the service to send authentication requests
@@ -20,15 +21,13 @@ export class BrowserAuthRequestService extends AuthRequestService {
} }
/** /**
* Factory function to create the request object to send. This needs to be a POST client side and * Factory function to create the request object to send.
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* *
* @param href The href to send the request to * @param href The href to send the request to
* @protected * @protected
*/ */
protected createShortLivedTokenRequest(href: string): PostRequest { protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return new PostRequest(this.requestService.generateRequestId(), href); return observableOf(new PostRequest(this.requestService.generateRequestId(), href));
} }
} }

View File

@@ -1,34 +1,68 @@
import { AuthRequestService } from './auth-request.service'; import { AuthRequestService } from './auth-request.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { ServerAuthRequestService } from './server-auth-request.service'; import { ServerAuthRequestService } from './server-auth-request.service';
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Observable, of as observableOf } from 'rxjs';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { PostRequest } from '../data/request.models';
import {
XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER
} from '../xsrf/xsrf.interceptor';
describe(`ServerAuthRequestService`, () => { describe(`ServerAuthRequestService`, () => {
let href: string; let href: string;
let requestService: RequestService; let requestService: RequestService;
let service: AuthRequestService; let service: AuthRequestService;
let httpClient: HttpClient;
let httpResponse: HttpResponse<any>;
let halService: HALEndpointService;
const mockToken = 'mock-token';
beforeEach(() => { beforeEach(() => {
href = 'https://rest.api/auth/shortlivedtokens'; href = 'https://rest.api/auth/shortlivedtokens';
requestService = jasmine.createSpyObj('requestService', { requestService = jasmine.createSpyObj('requestService', {
'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2'
}); });
service = new ServerAuthRequestService(null, requestService, null); let headers = new HttpHeaders();
headers = headers.set(XSRF_RESPONSE_HEADER, mockToken);
httpResponse = {
body: { bar: false },
headers: headers,
statusText: '200'
} as HttpResponse<any>;
httpClient = jasmine.createSpyObj('httpClient', {
get: observableOf(httpResponse),
});
halService = jasmine.createSpyObj('halService', {
'getRootHref': '/api'
});
service = new ServerAuthRequestService(halService, requestService, null, httpClient);
}); });
describe(`createShortLivedTokenRequest`, () => { describe(`createShortLivedTokenRequest`, () => {
it(`should return a GetRequest`, () => { it(`should return a PostRequest`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.constructor.name).toBe('GetRequest'); obs.subscribe((result: PostRequest) => {
expect(result.constructor.name).toBe('PostRequest');
done();
});
}); });
it(`should return a request with the given href`, () => { it(`should return a request with the given href`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.href).toBe(href) ; obs.subscribe((result: PostRequest) => {
expect(result.href).toBe(href);
done();
});
}); });
it(`should have a responseMsToLive of 2 seconds`, () => { it(`should return a request with a xsrf header`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.responseMsToLive).toBe(2 * 1000) ; obs.subscribe((result: PostRequest) => {
expect(result.options.headers.get(XSRF_REQUEST_HEADER)).toBe(mockToken);
done();
});
}); });
}); });
}); });

View File

@@ -1,9 +1,21 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { AuthRequestService } from './auth-request.service'; import { AuthRequestService } from './auth-request.service';
import { GetRequest } from '../data/request.models'; import { PostRequest } from '../data/request.models';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import {
HttpHeaders,
HttpClient,
HttpResponse
} from '@angular/common/http';
import {
XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER,
DSPACE_XSRF_COOKIE
} from '../xsrf/xsrf.interceptor';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
/** /**
* Server side version of the service to send authentication requests * Server side version of the service to send authentication requests
@@ -14,23 +26,42 @@ export class ServerAuthRequestService extends AuthRequestService {
constructor( constructor(
halService: HALEndpointService, halService: HALEndpointService,
requestService: RequestService, requestService: RequestService,
rdbService: RemoteDataBuildService rdbService: RemoteDataBuildService,
protected httpClient: HttpClient,
) { ) {
super(halService, requestService, rdbService); super(halService, requestService, rdbService);
} }
/** /**
* Factory function to create the request object to send. This needs to be a POST client side and * Factory function to create the request object to send.
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* *
* @param href The href to send the request to * @param href The href to send the request to
* @protected * @protected
*/ */
protected createShortLivedTokenRequest(href: string): GetRequest { protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return Object.assign(new GetRequest(this.requestService.generateRequestId(), href), { // First do a call to the root endpoint in order to get an XSRF token
responseMsToLive: 2 * 1000 // A short lived token is only valid for 2 seconds. return this.httpClient.get(this.halService.getRootHref(), { observe: 'response' }).pipe(
}); // retrieve the XSRF token from the response header
map((response: HttpResponse<any>) => response.headers.get(XSRF_RESPONSE_HEADER)),
// Use that token to create an HttpHeaders object
map((xsrfToken: string) => new HttpHeaders()
.set('Content-Type', 'application/json; charset=utf-8')
// set the token as the XSRF header
.set(XSRF_REQUEST_HEADER, xsrfToken)
// and as the DSPACE-XSRF-COOKIE
.set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)),
map((headers: HttpHeaders) =>
// Create a new PostRequest using those headers and the given href
new PostRequest(
this.requestService.generateRequestId(),
href,
{},
{
headers: headers,
},
)
)
);
} }
} }

View File

@@ -2,27 +2,66 @@ import { BrowseDefinitionDataService } from './browse-definition-data.service';
import { followLink } from '../../shared/utils/follow-link-config.model'; import { followLink } from '../../shared/utils/follow-link-config.model';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
import { RequestService } from '../data/request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock';
describe(`BrowseDefinitionDataService`, () => { describe(`BrowseDefinitionDataService`, () => {
let requestService: RequestService;
let service: BrowseDefinitionDataService; let service: BrowseDefinitionDataService;
const findAllDataSpy = jasmine.createSpyObj('findAllData', { let findAllDataSpy;
findAll: EMPTY, let searchDataSpy;
}); const browsesEndpointURL = 'https://rest.api/browses';
const halService: any = new HALEndpointServiceStub(browsesEndpointURL);
const options = new FindListOptions(); const options = new FindListOptions();
const linksToFollow = [ const linksToFollow = [
followLink('entries'), followLink('entries'),
followLink('items') followLink('items')
]; ];
function initTestService() {
return new BrowseDefinitionDataService(
requestService,
getMockRemoteDataBuildService(),
getMockObjectCacheService(),
halService,
);
}
beforeEach(() => { beforeEach(() => {
service = new BrowseDefinitionDataService(null, null, null, null); service = initTestService();
findAllDataSpy = jasmine.createSpyObj('findAllData', {
findAll: EMPTY,
});
searchDataSpy = jasmine.createSpyObj('searchData', {
searchBy: EMPTY,
getSearchByHref: EMPTY,
});
(service as any).findAllData = findAllDataSpy; (service as any).findAllData = findAllDataSpy;
(service as any).searchData = searchDataSpy;
}); });
describe('findByFields', () => {
it(`should call searchByHref on searchData`, () => {
service.findByFields(['test'], true, false, ...linksToFollow);
expect(searchDataSpy.getSearchByHref).toHaveBeenCalled();
});
});
describe('searchBy', () => {
it(`should call searchBy on searchData`, () => {
service.searchBy('test', options, true, false, ...linksToFollow);
expect(searchDataSpy.searchBy).toHaveBeenCalledWith('test', options, true, false, ...linksToFollow);
});
});
describe(`findAll`, () => { describe(`findAll`, () => {
it(`should call findAll on findAllData`, () => { it(`should call findAll on findAllData`, () => {
service.findAll(options, true, false, ...linksToFollow); service.findAll(options, true, false, ...linksToFollow);
expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow);
}); });
}); });
}); });

View File

@@ -13,6 +13,8 @@ import { FindListOptions } from '../data/find-list-options.model';
import { IdentifiableDataService } from '../data/base/identifiable-data.service'; import { IdentifiableDataService } from '../data/base/identifiable-data.service';
import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data';
import { dataService } from '../data/base/data-service.decorator'; import { dataService } from '../data/base/data-service.decorator';
import { RequestParam } from '../cache/models/request-param.model';
import { SearchData, SearchDataImpl } from '../data/base/search-data';
/** /**
* Data service responsible for retrieving browse definitions from the REST server * Data service responsible for retrieving browse definitions from the REST server
@@ -21,8 +23,9 @@ import { dataService } from '../data/base/data-service.decorator';
providedIn: 'root', providedIn: 'root',
}) })
@dataService(BROWSE_DEFINITION) @dataService(BROWSE_DEFINITION)
export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition> { export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition>, SearchData<BrowseDefinition> {
private findAllData: FindAllDataImpl<BrowseDefinition>; private findAllData: FindAllDataImpl<BrowseDefinition>;
private searchData: SearchDataImpl<BrowseDefinition>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
@@ -31,7 +34,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
protected halService: HALEndpointService, protected halService: HALEndpointService,
) { ) {
super('browses', requestService, rdbService, objectCache, halService); super('browses', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
} }
@@ -52,5 +55,71 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> { findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create the HREF for a specific object's search method with given options object
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<string> {
return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow);
}
/**
* Get the browse URL by providing a list of metadata keys. The first matching browse index definition
* for any of the fields is returned. This is used in eg. item page field component, which can be configured
* with several fields for a component like 'Author', and needs to know if and how to link the values
* to configured browse indices.
*
* @param fields an array of field strings, eg. ['dc.contributor.author', 'dc.creator']
* @param useCachedVersionIfAvailable Override the data service useCachedVersionIfAvailable parameter (default: true)
* @param reRequestOnStale Override the data service reRequestOnStale parameter (default: true)
* @param linksToFollow Override the data service linksToFollow parameter (default: empty array)
*/
findByFields(
fields: string[],
useCachedVersionIfAvailable = true,
reRequestOnStale = true,
...linksToFollow: FollowLinkConfig<BrowseDefinition>[]
): Observable<RemoteData<BrowseDefinition>> {
const searchParams = [];
searchParams.push(new RequestParam('fields', fields));
const hrefObs = this.getSearchByHref(
'byFields',
{ searchParams },
...linksToFollow
);
return this.findByHref(
hrefObs,
useCachedVersionIfAvailable,
reRequestOnStale,
...linksToFollow,
);
}
} }

View File

@@ -19,9 +19,9 @@ import {
} from '../shared/operators'; } from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner'; import { URLCombiner } from '../url-combiner/url-combiner';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
import { BrowseDefinitionDataService } from './browse-definition-data.service';
import { HrefOnlyDataService } from '../data/href-only-data.service'; import { HrefOnlyDataService } from '../data/href-only-data.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { BrowseDefinitionDataService } from './browse-definition-data.service';
export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [ export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [
@@ -35,7 +35,7 @@ export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [
export class BrowseService { export class BrowseService {
protected linkPath = 'browses'; protected linkPath = 'browses';
private static toSearchKeyArray(metadataKey: string): string[] { public static toSearchKeyArray(metadataKey: string): string[] {
const keyParts = metadataKey.split('.'); const keyParts = metadataKey.split('.');
const searchFor = []; const searchFor = [];
searchFor.push('*'); searchFor.push('*');

View File

@@ -14,6 +14,7 @@ import { RemoteData } from './remote-data';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { HttpParams } from '@angular/common/http';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -55,7 +56,7 @@ export class EpersonRegistrationService {
* @param email * @param email
* @param captchaToken the value of x-recaptcha-token header * @param captchaToken the value of x-recaptcha-token header
*/ */
registerEmail(email: string, captchaToken: string = null): Observable<RemoteData<Registration>> { registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
const registration = new Registration(); const registration = new Registration();
registration.email = email; registration.email = email;
@@ -70,6 +71,11 @@ export class EpersonRegistrationService {
} }
options.headers = headers; options.headers = headers;
if (hasValue(type)) {
options.params = type ?
new HttpParams({ fromString: 'accountRequestType=' + type }) : new HttpParams();
}
href$.pipe( href$.pipe(
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
map((href: string) => { map((href: string) => {

View File

@@ -0,0 +1,16 @@
import { XhrFactory } from '@angular/common';
import { Injectable } from '@angular/core';
import { prototype, XMLHttpRequest } from 'xhr2';
/**
* Overrides the default XhrFactory server side, to allow us to set cookies in requests to the
* backend. This was added to be able to perform a working XSRF request from the node server, as it
* needs to set a cookie for the XSRF token
*/
@Injectable()
export class ServerXhrService implements XhrFactory {
build(): XMLHttpRequest {
prototype._restrictedHeaders.cookie = false;
return new XMLHttpRequest();
}
}

View File

@@ -1,11 +1,14 @@
/** /**
* An Enum defining the representation type of metadata * An Enum defining the representation type of metadata
*/ */
import { BrowseDefinition } from '../browse-definition.model';
export enum MetadataRepresentationType { export enum MetadataRepresentationType {
None = 'none', None = 'none',
Item = 'item', Item = 'item',
AuthorityControlled = 'authority_controlled', AuthorityControlled = 'authority_controlled',
PlainText = 'plain_text' PlainText = 'plain_text',
BrowseLink = 'browse_link'
} }
/** /**
@@ -24,8 +27,14 @@ export interface MetadataRepresentation {
*/ */
representationType: MetadataRepresentationType; representationType: MetadataRepresentationType;
/**
* The browse definition (optional)
*/
browseDefinition?: BrowseDefinition;
/** /**
* Fetches the value to be displayed * Fetches the value to be displayed
*/ */
getValue(): string; getValue(): string;
} }

View File

@@ -1,6 +1,7 @@
import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model';
import { hasValue } from '../../../../shared/empty.util'; import { hasValue } from '../../../../shared/empty.util';
import { MetadataValue } from '../../metadata.models'; import { MetadataValue } from '../../metadata.models';
import { BrowseDefinition } from '../../browse-definition.model';
/** /**
* This class defines the way the metadatum it extends should be represented * This class defines the way the metadatum it extends should be represented
@@ -12,9 +13,15 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe
*/ */
itemType: string; itemType: string;
constructor(itemType: string) { /**
* The browse definition ID passed in with the metadatum, if any
*/
browseDefinition?: BrowseDefinition;
constructor(itemType: string, browseDefinition?: BrowseDefinition) {
super(); super();
this.itemType = itemType; this.itemType = itemType;
this.browseDefinition = browseDefinition;
} }
/** /**
@@ -23,6 +30,8 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe
get representationType(): MetadataRepresentationType { get representationType(): MetadataRepresentationType {
if (hasValue(this.authority)) { if (hasValue(this.authority)) {
return MetadataRepresentationType.AuthorityControlled; return MetadataRepresentationType.AuthorityControlled;
} else if (hasValue(this.browseDefinition)) {
return MetadataRepresentationType.BrowseLink;
} else { } else {
return MetadataRepresentationType.PlainText; return MetadataRepresentationType.PlainText;
} }

View File

@@ -19,6 +19,8 @@ export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN';
export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN';
// Name of cookie where we store the XSRF token // Name of cookie where we store the XSRF token
export const XSRF_COOKIE = 'XSRF-TOKEN'; export const XSRF_COOKIE = 'XSRF-TOKEN';
// Name of cookie the backend expects the XSRF token to be in
export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE';
/** /**
* Custom Http Interceptor intercepting Http Requests & Responses to * Custom Http Interceptor intercepting Http Requests & Responses to

View File

@@ -35,6 +35,10 @@ import { VersionDataService } from '../../../../core/data/version-data.service';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchService } from '../../../../core/shared/search/search.service';
import { mockRouteService } from '../../../../item-page/simple/item-types/shared/item.component.spec'; import { mockRouteService } from '../../../../item-page/simple/item-types/shared/item.component.spec';
import {
BrowseDefinitionDataServiceStub
} from '../../../../shared/testing/browse-definition-data-service.stub';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
let comp: JournalComponent; let comp: JournalComponent;
let fixture: ComponentFixture<JournalComponent>; let fixture: ComponentFixture<JournalComponent>;
@@ -100,7 +104,8 @@ describe('JournalComponent', () => {
{ provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: WorkspaceitemDataService, useValue: {} }, { provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} }, { provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService } { provide: RouteService, useValue: mockRouteService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]

View File

@@ -1,3 +1,3 @@
<ds-register-email-form <ds-register-email-form
[MESSAGE_PREFIX]="'forgot-email.form'"> [MESSAGE_PREFIX]="'forgot-email.form'" [typeRequest]="typeRequest">
</ds-register-email-form> </ds-register-email-form>

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { TYPE_REQUEST_FORGOT } from '../../register-email-form/register-email-form.component';
@Component({ @Component({
selector: 'ds-forgot-email', selector: 'ds-forgot-email',
@@ -9,5 +10,5 @@ import { Component } from '@angular/core';
* Component responsible the forgot password email step * Component responsible the forgot password email step
*/ */
export class ForgotEmailComponent { export class ForgotEmailComponent {
typeRequest = TYPE_REQUEST_FORGOT;
} }

View File

@@ -3,8 +3,8 @@
<div class="col-12"> <div class="col-12">
<h2 class="border-bottom">{{'item.edit.head' | translate}}</h2> <h2 class="border-bottom">{{'item.edit.head' | translate}}</h2>
<div class="pt-2"> <div class="pt-2">
<ul class="nav nav-tabs justify-content-start"> <ul class="nav nav-tabs justify-content-start" role="tablist">
<li *ngFor="let page of pages" class="nav-item"> <li *ngFor="let page of pages" class="nav-item" [attr.aria-selected]="page.page === currentPage" role="tab">
<a *ngIf="(page.enabled | async)" <a *ngIf="(page.enabled | async)"
class="nav-link" class="nav-link"
[ngClass]="{'active' : page.page === currentPage}" [ngClass]="{'active' : page.page === currentPage}"

View File

@@ -1,16 +1,38 @@
<ds-metadata-field-wrapper [label]="label | translate"> <ds-metadata-field-wrapper [label]="label | translate">
<ng-container *ngFor="let mdValue of mdValues; let last=last;"> <ng-container *ngFor="let mdValue of mdValues; let last=last;">
<ng-container *ngTemplateOutlet="(renderMarkdown ? markdown : simple); context: {value: mdValue.value}"> <!--
Choose a template. Priority: markdown, link, browse link.
-->
<ng-container *ngTemplateOutlet="(renderMarkdown ? markdown : (hasLink(mdValue) ? link : (hasBrowseDefinition() ? browselink : simple)));
context: {value: mdValue.value}">
</ng-container> </ng-container>
<span class="separator" *ngIf="!last" [innerHTML]="separator"></span> <span class="separator" *ngIf="!last" [innerHTML]="separator"></span>
</ng-container> </ng-container>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<!-- Render value as markdown -->
<ng-template #markdown let-value="value"> <ng-template #markdown let-value="value">
<span class="dont-break-out" [innerHTML]="value | dsMarkdown | async"> <span class="dont-break-out" [innerHTML]="value | dsMarkdown | async">
</span> </span>
</ng-template> </ng-template>
<!-- Render value as a link (href and label) -->
<ng-template #link let-value="value">
<a class="dont-break-out ds-simple-metadata-link" target="_blank" [href]="value">
{{value}}
</a>
</ng-template>
<!-- Render simple value in a span -->
<ng-template #simple let-value="value"> <ng-template #simple let-value="value">
<span class="dont-break-out preserve-line-breaks">{{value}}</span> <span class="dont-break-out preserve-line-breaks">{{value}}</span>
</ng-template> </ng-template>
<!-- Render value as a link to browse index -->
<ng-template #browselink let-value="value">
<a class="dont-break-out preserve-line-breaks ds-browse-link"
[routerLink]="['/browse', browseDefinition.id]"
[queryParams]="getQueryParams(value)">
{{value}}
</a>
</ng-template>

View File

@@ -52,6 +52,7 @@ describe('MetadataValuesComponent', () => {
comp.mdValues = mockMetadata; comp.mdValues = mockMetadata;
comp.separator = mockSeperator; comp.separator = mockSeperator;
comp.label = mockLabel; comp.label = mockLabel;
comp.urlRegex = /^.*test.*$/;
fixture.detectChanges(); fixture.detectChanges();
})); }));
@@ -67,4 +68,9 @@ describe('MetadataValuesComponent', () => {
expect(separators.length).toBe(mockMetadata.length - 1); expect(separators.length).toBe(mockMetadata.length - 1);
}); });
it('should correctly detect a pattern on string containing "test"', () => {
const mdValue = {value: 'This is a test value'} as MetadataValue;
expect(comp.hasLink(mdValue)).toBe(true);
});
}); });

View File

@@ -1,6 +1,8 @@
import { Component, Inject, Input, OnChanges, SimpleChanges } from '@angular/core'; import { Component, Inject, Input, OnChanges, SimpleChanges } from '@angular/core';
import { MetadataValue } from '../../../core/shared/metadata.models'; import { MetadataValue } from '../../../core/shared/metadata.models';
import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface';
import { BrowseDefinition } from '../../../core/shared/browse-definition.model';
import { hasValue } from '../../../shared/empty.util';
/** /**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component. * This component renders the configured 'values' into the ds-metadata-field-wrapper component.
@@ -40,12 +42,51 @@ export class MetadataValuesComponent implements OnChanges {
*/ */
@Input() enableMarkdown = false; @Input() enableMarkdown = false;
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
@Input() urlRegex?;
/** /**
* This variable will be true if both {@link environment.markdown.enabled} and {@link enableMarkdown} are true. * This variable will be true if both {@link environment.markdown.enabled} and {@link enableMarkdown} are true.
*/ */
renderMarkdown; renderMarkdown;
@Input() browseDefinition?: BrowseDefinition;
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
this.renderMarkdown = !!this.appConfig.markdown.enabled && this.enableMarkdown; this.renderMarkdown = !!this.appConfig.markdown.enabled && this.enableMarkdown;
} }
/**
* Does this metadata value have a configured link to a browse definition?
*/
hasBrowseDefinition(): boolean {
return hasValue(this.browseDefinition);
}
/**
* Does this metadata value have a valid URL that should be rendered as a link?
* @param value A MetadataValue being displayed
*/
hasLink(value: MetadataValue): boolean {
if (hasValue(this.urlRegex)) {
const pattern = new RegExp(this.urlRegex);
return pattern.test(value.value);
}
return false;
}
/**
* Return a queryparams object for use in a link, with the key dependent on whether this browse
* definition is metadata browse, or item browse
* @param value the specific metadata value being linked
*/
getQueryParams(value) {
let queryParams = {startsWith: value};
if (this.browseDefinition.metadataBrowse) {
return {value: value};
}
return queryParams;
}
} }

View File

@@ -7,6 +7,8 @@ import { SharedModule } from '../../../../../shared/shared.module';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: ItemPageAbstractFieldComponent; let comp: ItemPageAbstractFieldComponent;
let fixture: ComponentFixture<ItemPageAbstractFieldComponent>; let fixture: ComponentFixture<ItemPageAbstractFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageAbstractFieldComponent', () => {
], ],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [ItemPageAbstractFieldComponent], declarations: [ItemPageAbstractFieldComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]

View File

@@ -3,10 +3,12 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageAuthorFieldComponent } from './item-page-author-field.component'; import { ItemPageAuthorFieldComponent } from './item-page-author-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: ItemPageAuthorFieldComponent; let comp: ItemPageAuthorFieldComponent;
let fixture: ComponentFixture<ItemPageAuthorFieldComponent>; let fixture: ComponentFixture<ItemPageAuthorFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageAuthorFieldComponent', () => {
})], })],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent], declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -37,7 +40,7 @@ describe('ItemPageAuthorFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageAuthorFieldComponent); fixture = TestBed.createComponent(ItemPageAuthorFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(field, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([field], mockValue);
fixture.detectChanges(); fixture.detectChanges();
})); }));

View File

@@ -3,10 +3,12 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageDateFieldComponent } from './item-page-date-field.component'; import { ItemPageDateFieldComponent } from './item-page-date-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: ItemPageDateFieldComponent; let comp: ItemPageDateFieldComponent;
let fixture: ComponentFixture<ItemPageDateFieldComponent>; let fixture: ComponentFixture<ItemPageDateFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageDateFieldComponent', () => {
})], })],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [ItemPageDateFieldComponent, MetadataValuesComponent], declarations: [ItemPageDateFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -36,7 +39,7 @@ describe('ItemPageDateFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageDateFieldComponent); fixture = TestBed.createComponent(ItemPageDateFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
fixture.detectChanges(); fixture.detectChanges();
})); }));

View File

@@ -3,10 +3,12 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { GenericItemPageFieldComponent } from './generic-item-page-field.component'; import { GenericItemPageFieldComponent } from './generic-item-page-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: GenericItemPageFieldComponent; let comp: GenericItemPageFieldComponent;
let fixture: ComponentFixture<GenericItemPageFieldComponent>; let fixture: ComponentFixture<GenericItemPageFieldComponent>;
@@ -27,6 +29,7 @@ describe('GenericItemPageFieldComponent', () => {
})], })],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [GenericItemPageFieldComponent, MetadataValuesComponent], declarations: [GenericItemPageFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -38,7 +41,7 @@ describe('GenericItemPageFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(GenericItemPageFieldComponent); fixture = TestBed.createComponent(GenericItemPageFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
comp.fields = mockFields; comp.fields = mockFields;
comp.label = mockLabel; comp.label = mockLabel;
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -40,5 +40,10 @@ export class GenericItemPageFieldComponent extends ItemPageFieldComponent {
*/ */
@Input() enableMarkdown = false; @Input() enableMarkdown = false;
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
@Input() urlRegex?: string;
} }

View File

@@ -4,5 +4,7 @@
[separator]="separator" [separator]="separator"
[label]="label" [label]="label"
[enableMarkdown]="enableMarkdown" [enableMarkdown]="enableMarkdown"
[urlRegex]="urlRegex"
[browseDefinition]="browseDefinition|async"
></ds-metadata-values> ></ds-metadata-values>
</div> </div>

View File

@@ -12,6 +12,10 @@ import { environment } from '../../../../../environments/environment';
import { MarkdownPipe } from '../../../../shared/utils/markdown.pipe'; import { MarkdownPipe } from '../../../../shared/utils/markdown.pipe';
import { SharedModule } from '../../../../shared/shared.module'; import { SharedModule } from '../../../../shared/shared.module';
import { APP_CONFIG } from '../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../config/app-config.interface';
import { By } from '@angular/platform-browser';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../shared/testing/browse-definition-data-service.stub';
import { RouterTestingModule } from '@angular/router/testing';
let comp: ItemPageFieldComponent; let comp: ItemPageFieldComponent;
let fixture: ComponentFixture<ItemPageFieldComponent>; let fixture: ComponentFixture<ItemPageFieldComponent>;
@@ -20,7 +24,9 @@ let markdownSpy;
const mockValue = 'test value'; const mockValue = 'test value';
const mockField = 'dc.test'; const mockField = 'dc.test';
const mockLabel = 'test label'; const mockLabel = 'test label';
const mockFields = [mockField]; const mockAuthorField = 'dc.contributor.author';
const mockDateIssuedField = 'dc.date.issued';
const mockFields = [mockField, mockAuthorField, mockDateIssuedField];
describe('ItemPageFieldComponent', () => { describe('ItemPageFieldComponent', () => {
@@ -34,6 +40,7 @@ describe('ItemPageFieldComponent', () => {
const buildTestEnvironment = async () => { const buildTestEnvironment = async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ imports: [
RouterTestingModule.withRoutes([]),
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
@@ -44,6 +51,7 @@ describe('ItemPageFieldComponent', () => {
], ],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: appConfig }, { provide: APP_CONFIG, useValue: appConfig },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [ItemPageFieldComponent, MetadataValuesComponent], declarations: [ItemPageFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -53,7 +61,7 @@ describe('ItemPageFieldComponent', () => {
markdownSpy = spyOn(MarkdownPipe.prototype, 'transform'); markdownSpy = spyOn(MarkdownPipe.prototype, 'transform');
fixture = TestBed.createComponent(ItemPageFieldComponent); fixture = TestBed.createComponent(ItemPageFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue(mockFields, mockValue);
comp.fields = mockFields; comp.fields = mockFields;
comp.label = mockLabel; comp.label = mockLabel;
fixture.detectChanges(); fixture.detectChanges();
@@ -126,17 +134,57 @@ describe('ItemPageFieldComponent', () => {
expect(markdownSpy).toHaveBeenCalled(); expect(markdownSpy).toHaveBeenCalled();
}); });
}); });
}); });
describe('test rendering of configured browse links', () => {
beforeEach(() => {
fixture.detectChanges();
});
waitForAsync(() => {
it('should have a browse link', () => {
expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockValue);
});
});
});
describe('test rendering of configured regex-based links', () => {
beforeEach(() => {
comp.urlRegex = '^test';
fixture.detectChanges();
});
waitForAsync(() => {
it('should have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link')).nativeElement.innerHTML).toContain(mockValue);
});
});
});
describe('test skipping of configured links that do NOT match regex', () => {
beforeEach(() => {
comp.urlRegex = '^nope';
fixture.detectChanges();
});
beforeEach(waitForAsync(() => {
it('should NOT have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link'))).toBeNull();
});
}));
});
}); });
export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item { export function mockItemWithMetadataFieldsAndValue(fields: string[], value: string): Item {
const item = Object.assign(new Item(), { const item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: new MetadataMap() metadata: new MetadataMap()
}); });
item.metadata[field] = [{ fields.forEach((field: string) => {
language: 'en_US', item.metadata[field] = [{
value: value language: 'en_US',
}] as MetadataValue[]; value: value
}] as MetadataValue[];
});
return item; return item;
} }

View File

@@ -1,5 +1,10 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { BrowseDefinition } from '../../../../core/shared/browse-definition.model';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import { getRemoteDataPayload } from '../../../../core/shared/operators';
/** /**
* This component can be used to represent metadata on a simple item page. * This component can be used to represent metadata on a simple item page.
@@ -12,6 +17,9 @@ import { Item } from '../../../../core/shared/item.model';
}) })
export class ItemPageFieldComponent { export class ItemPageFieldComponent {
constructor(protected browseDefinitionDataService: BrowseDefinitionDataService) {
}
/** /**
* The item to display metadata for * The item to display metadata for
*/ */
@@ -38,4 +46,19 @@ export class ItemPageFieldComponent {
*/ */
separator = '<br/>'; separator = '<br/>';
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
urlRegex?: string;
/**
* Return browse definition that matches any field used in this component if it is configured as a browse
* link in dspace.cfg (webui.browse.link.<n>)
*/
get browseDefinition(): Observable<BrowseDefinition> {
return this.browseDefinitionDataService.findByFields(this.fields).pipe(
getRemoteDataPayload(),
map((def) => def)
);
}
} }

View File

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageTitleFieldComponent } from './item-page-title-field.component'; import { ItemPageTitleFieldComponent } from './item-page-title-field.component';
let comp: ItemPageTitleFieldComponent; let comp: ItemPageTitleFieldComponent;
@@ -31,7 +31,7 @@ describe('ItemPageTitleFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageTitleFieldComponent); fixture = TestBed.createComponent(ItemPageTitleFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
fixture.detectChanges(); fixture.detectChanges();
})); }));

View File

@@ -2,11 +2,13 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageUriFieldComponent } from './item-page-uri-field.component'; import { ItemPageUriFieldComponent } from './item-page-uri-field.component';
import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component'; import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: ItemPageUriFieldComponent; let comp: ItemPageUriFieldComponent;
let fixture: ComponentFixture<ItemPageUriFieldComponent>; let fixture: ComponentFixture<ItemPageUriFieldComponent>;
@@ -26,6 +28,7 @@ describe('ItemPageUriFieldComponent', () => {
})], })],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [ItemPageUriFieldComponent, MetadataUriValuesComponent], declarations: [ItemPageUriFieldComponent, MetadataUriValuesComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -37,7 +40,7 @@ describe('ItemPageUriFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageUriFieldComponent); fixture = TestBed.createComponent(ItemPageUriFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
comp.fields = [mockField]; comp.fields = [mockField];
comp.label = mockLabel; comp.label = mockLabel;
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -36,6 +36,10 @@ import { VersionDataService } from '../../../../core/data/version-data.service';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchService } from '../../../../core/shared/search/search.service';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import {
BrowseDefinitionDataServiceStub
} from '../../../../shared/testing/browse-definition-data-service.stub';
const noMetadata = new MetadataMap(); const noMetadata = new MetadataMap();
@@ -87,7 +91,8 @@ describe('PublicationComponent', () => {
{ provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: WorkspaceitemDataService, useValue: {} }, { provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} }, { provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService } { provide: RouteService, useValue: mockRouteService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]

View File

@@ -23,7 +23,9 @@ import { UUIDService } from '../../../../core/shared/uuid.service';
import { isNotEmpty } from '../../../../shared/empty.util'; import { isNotEmpty } from '../../../../shared/empty.util';
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import {
createSuccessfulRemoteDataObject$
} from '../../../../shared/remote-data.utils';
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
@@ -38,6 +40,10 @@ import { VersionHistoryDataService } from '../../../../core/data/version-history
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { ResearcherProfileDataService } from '../../../../core/profile/researcher-profile-data.service'; import { ResearcherProfileDataService } from '../../../../core/profile/researcher-profile-data.service';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import {
BrowseDefinitionDataServiceStub
} from '../../../../shared/testing/browse-definition-data-service.stub';
import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; import { buildPaginatedList } from '../../../../core/data/paginated-list.model';
import { PageInfo } from '../../../../core/shared/page-info.model'; import { PageInfo } from '../../../../core/shared/page-info.model';
@@ -125,7 +131,8 @@ export function getItemPageFieldsTest(mockItem: Item, component) {
{ provide: SearchService, useValue: {} }, { provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService }, { provide: RouteService, useValue: mockRouteService },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: ResearcherProfileDataService, useValue: {} } { provide: ResearcherProfileDataService, useValue: {} },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -444,7 +451,7 @@ describe('ItemComponent', () => {
{ provide: SearchService, useValue: {} }, { provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService }, { provide: RouteService, useValue: mockRouteService },
{ provide: AuthorizationDataService, useValue: {} }, { provide: AuthorizationDataService, useValue: {} },
{ provide: ResearcherProfileDataService, useValue: {} } { provide: ResearcherProfileDataService, useValue: {} },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemComponent, { }).overrideComponent(ItemComponent, {

View File

@@ -37,6 +37,10 @@ import { RouterTestingModule } from '@angular/router/testing';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchService } from '../../../../core/shared/search/search.service';
import { ItemVersionsSharedService } from '../../../versions/item-versions-shared.service'; import { ItemVersionsSharedService } from '../../../versions/item-versions-shared.service';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import {
BrowseDefinitionDataServiceStub
} from '../../../../shared/testing/browse-definition-data-service.stub';
const noMetadata = new MetadataMap(); const noMetadata = new MetadataMap();
@@ -90,7 +94,8 @@ describe('UntypedItemComponent', () => {
{ provide: SearchService, useValue: {} }, { provide: SearchService, useValue: {} },
{ provide: ItemDataService, useValue: {} }, { provide: ItemDataService, useValue: {} },
{ provide: ItemVersionsSharedService, useValue: {} }, { provide: ItemVersionsSharedService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService } { provide: RouteService, useValue: mockRouteService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(UntypedItemComponent, { }).overrideComponent(UntypedItemComponent, {

View File

@@ -11,6 +11,8 @@ import { MetadataValue } from '../../../core/shared/metadata.models';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { BrowseDefinitionDataService } from '../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../shared/testing/browse-definition-data-service.stub';
const itemType = 'Person'; const itemType = 'Person';
const metadataFields = ['dc.contributor.author', 'dc.creator']; const metadataFields = ['dc.contributor.author', 'dc.creator'];
@@ -104,7 +106,8 @@ describe('MetadataRepresentationListComponent', () => {
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [MetadataRepresentationListComponent, VarDirective], declarations: [MetadataRepresentationListComponent, VarDirective],
providers: [ providers: [
{ provide: RelationshipDataService, useValue: relationshipService } { provide: RelationshipDataService, useValue: relationshipService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MetadataRepresentationListComponent, { }).overrideComponent(MetadataRepresentationListComponent, {

View File

@@ -8,6 +8,13 @@ import { RelationshipDataService } from '../../../core/data/relationship-data.se
import { MetadataValue } from '../../../core/shared/metadata.models'; import { MetadataValue } from '../../../core/shared/metadata.models';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component'; import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component';
import { map } from 'rxjs/operators';
import { getRemoteDataPayload } from '../../../core/shared/operators';
import {
MetadatumRepresentation
} from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { BrowseService } from '../../../core/browse/browse.service';
import { BrowseDefinitionDataService } from '../../../core/browse/browse-definition-data.service';
@Component({ @Component({
selector: 'ds-metadata-representation-list', selector: 'ds-metadata-representation-list',
@@ -52,7 +59,8 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList
*/ */
total: number; total: number;
constructor(public relationshipService: RelationshipDataService) { constructor(public relationshipService: RelationshipDataService,
private browseDefinitionDataService: BrowseDefinitionDataService) {
super(); super();
} }
@@ -76,7 +84,21 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList
...metadata ...metadata
.slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy) .slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy)
.map((metadatum: any) => Object.assign(new MetadataValue(), metadatum)) .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum))
.map((metadatum: MetadataValue) => this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType)), .map((metadatum: MetadataValue) => {
if (metadatum.isVirtual) {
return this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType);
} else {
// Check for a configured browse link and return a standard metadata representation
let searchKeyArray: string[] = [];
this.metadataFields.forEach((field: string) => {
searchKeyArray = searchKeyArray.concat(BrowseService.toSearchKeyArray(field));
});
return this.browseDefinitionDataService.findByFields(this.metadataFields).pipe(
getRemoteDataPayload(),
map((def) => Object.assign(new MetadatumRepresentation(this.itemType, def), metadatum))
);
}
}),
); );
} }
} }

View File

@@ -2,6 +2,10 @@
<h2>{{MESSAGE_PREFIX + '.header'|translate}}</h2> <h2>{{MESSAGE_PREFIX + '.header'|translate}}</h2>
<p>{{MESSAGE_PREFIX + '.info' | translate}}</p> <p>{{MESSAGE_PREFIX + '.info' | translate}}</p>
<p *ngIf="validMailDomains.length != 0 && typeRequest === TYPE_REQUEST_REGISTER">
{{ MESSAGE_PREFIX + '.info.maildomain' | translate}} {{ validMailDomains.join(', ')}}
</p>
<form [class]="'ng-invalid'" [formGroup]="form"> <form [class]="'ng-invalid'" [formGroup]="form">
<div class="form-group"> <div class="form-group">
@@ -16,8 +20,11 @@
<span *ngIf="email.errors && email.errors.required"> <span *ngIf="email.errors && email.errors.required">
{{ MESSAGE_PREFIX + '.email.error.required' | translate }} {{ MESSAGE_PREFIX + '.email.error.required' | translate }}
</span> </span>
<span *ngIf="email.errors && email.errors.pattern"> <span *ngIf="email.errors && ((email.errors.pattern && this.typeRequest === TYPE_REQUEST_REGISTER) || email.errors.email)">
{{ MESSAGE_PREFIX + '.email.error.pattern' | translate }} {{ MESSAGE_PREFIX + '.email.error.not-email-form' | translate }}
<ng-container *ngIf="validMailDomains.length > 0">
{{ MESSAGE_PREFIX + '.email.error.not-valid-domain' | translate: { domains: validMailDomains.join(', ') } }}
</ng-container>
</span> </span>
</div> </div>
</div> </div>
@@ -53,5 +60,4 @@
</ng-template> </ng-template>
</form> </form>
</div> </div>

View File

@@ -12,14 +12,19 @@ import { EpersonRegistrationService } from '../core/data/eperson-registration.se
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { RouterStub } from '../shared/testing/router.stub'; import { RouterStub } from '../shared/testing/router.stub';
import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub';
import { RegisterEmailFormComponent } from './register-email-form.component'; import {
RegisterEmailFormComponent,
TYPE_REQUEST_REGISTER,
TYPE_REQUEST_FORGOT
} from './register-email-form.component';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { ConfigurationDataService } from '../core/data/configuration-data.service';
import { GoogleRecaptchaService } from '../core/google-recaptcha/google-recaptcha.service'; import { GoogleRecaptchaService } from '../core/google-recaptcha/google-recaptcha.service';
import { CookieService } from '../core/services/cookie.service'; import { CookieService } from '../core/services/cookie.service';
import { CookieServiceMock } from '../shared/mocks/cookie.service.mock'; import { CookieServiceMock } from '../shared/mocks/cookie.service.mock';
import { ConfigurationProperty } from '../core/shared/configuration-property.model';
describe('RegisterEmailComponent', () => { describe('RegisterEmailFormComponent', () => {
let comp: RegisterEmailFormComponent; let comp: RegisterEmailFormComponent;
let fixture: ComponentFixture<RegisterEmailFormComponent>; let fixture: ComponentFixture<RegisterEmailFormComponent>;
@@ -53,6 +58,8 @@ describe('RegisterEmailComponent', () => {
registerEmail: createSuccessfulRemoteDataObject$({}) registerEmail: createSuccessfulRemoteDataObject$({})
}); });
jasmine.getEnv().allowRespy(true);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule],
declarations: [RegisterEmailFormComponent], declarations: [RegisterEmailFormComponent],
@@ -95,17 +102,53 @@ describe('RegisterEmailComponent', () => {
comp.form.patchValue({email: 'valid@email.org'}); comp.form.patchValue({email: 'valid@email.org'});
expect(comp.form.invalid).toBeFalse(); expect(comp.form.invalid).toBeFalse();
}); });
it('should accept email with other domain names on TYPE_REQUEST_FORGOT form', () => {
spyOn(configurationDataService, 'findByPropertyName').and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'authentication-password.domain.valid',
values: ['marvel.com'],
})));
comp.typeRequest = TYPE_REQUEST_FORGOT;
comp.ngOnInit();
comp.form.patchValue({ email: 'valid@email.org' });
expect(comp.form.invalid).toBeFalse();
});
it('should be valid when uppercase letters are used', () => { it('should be valid when uppercase letters are used', () => {
comp.form.patchValue({email: 'VALID@email.org'}); comp.form.patchValue({email: 'VALID@email.org'});
expect(comp.form.invalid).toBeFalse(); expect(comp.form.invalid).toBeFalse();
}); });
it('should not accept email with other domain names', () => {
spyOn(configurationDataService, 'findByPropertyName').and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'authentication-password.domain.valid',
values: ['marvel.com'],
})));
comp.typeRequest = TYPE_REQUEST_REGISTER;
comp.ngOnInit();
comp.form.patchValue({ email: 'valid@email.org' });
expect(comp.form.invalid).toBeTrue();
});
it('should accept email with the given domain name', () => {
spyOn(configurationDataService, 'findByPropertyName').and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'authentication-password.domain.valid',
values: ['marvel.com'],
})));
comp.typeRequest = TYPE_REQUEST_REGISTER;
comp.ngOnInit();
comp.form.patchValue({ email: 'thor.odinson@marvel.com' });
expect(comp.form.invalid).toBeFalse();
});
}); });
describe('register', () => { describe('register', () => {
it('should send a registration to the service and on success display a message and return to home', () => { it('should send a registration to the service and on success display a message and return to home', () => {
comp.form.patchValue({email: 'valid@email.org'}); comp.form.patchValue({email: 'valid@email.org'});
comp.register(); comp.register();
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', null, null);
expect(notificationsService.success).toHaveBeenCalled(); expect(notificationsService.success).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/home']); expect(router.navigate).toHaveBeenCalledWith(['/home']);
}); });
@@ -115,7 +158,7 @@ describe('RegisterEmailComponent', () => {
comp.form.patchValue({email: 'valid@email.org'}); comp.form.patchValue({email: 'valid@email.org'});
comp.register(); comp.register();
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', null, null);
expect(notificationsService.error).toHaveBeenCalled(); expect(notificationsService.error).toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled();
}); });
@@ -133,7 +176,7 @@ describe('RegisterEmailComponent', () => {
comp.form.patchValue({email: 'valid@email.org'}); comp.form.patchValue({email: 'valid@email.org'});
comp.register(); comp.register();
tick(); tick();
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken'); expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken', null);
expect(notificationsService.success).toHaveBeenCalled(); expect(notificationsService.success).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/home']); expect(router.navigate).toHaveBeenCalledWith(['/home']);
})); }));
@@ -144,7 +187,7 @@ describe('RegisterEmailComponent', () => {
comp.register(); comp.register();
tick(); tick();
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken'); expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken', null);
expect(notificationsService.error).toHaveBeenCalled(); expect(notificationsService.error).toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled();
})); }));

View File

@@ -1,21 +1,25 @@
import { ChangeDetectorRef, Component, Input, OnInit, Optional } from '@angular/core'; import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, Optional } from '@angular/core';
import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; import {EpersonRegistrationService} from '../core/data/eperson-registration.service';
import { NotificationsService } from '../shared/notifications/notifications.service'; import {NotificationsService} from '../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import {TranslateService} from '@ngx-translate/core';
import { Router } from '@angular/router'; import {Router} from '@angular/router';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormControl, FormGroup, Validators, ValidatorFn } from '@angular/forms';
import { Registration } from '../core/shared/registration.model'; import {Registration} from '../core/shared/registration.model';
import { RemoteData } from '../core/data/remote-data'; import {RemoteData} from '../core/data/remote-data';
import { ConfigurationDataService } from '../core/data/configuration-data.service'; import {ConfigurationDataService} from '../core/data/configuration-data.service';
import { getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../core/shared/operators';
import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import {ConfigurationProperty} from '../core/shared/configuration-property.model';
import { isNotEmpty } from '../shared/empty.util'; import {isNotEmpty} from '../shared/empty.util';
import { BehaviorSubject, combineLatest, Observable, of, switchMap } from 'rxjs'; import {BehaviorSubject, combineLatest, Observable, of, switchMap} from 'rxjs';
import { map, startWith, take } from 'rxjs/operators'; import {map, startWith, take} from 'rxjs/operators';
import { CAPTCHA_NAME, GoogleRecaptchaService } from '../core/google-recaptcha/google-recaptcha.service'; import {CAPTCHA_NAME, GoogleRecaptchaService} from '../core/google-recaptcha/google-recaptcha.service';
import { AlertType } from '../shared/alert/aletr-type'; import {AlertType} from '../shared/alert/aletr-type';
import { KlaroService } from '../shared/cookies/klaro.service'; import {KlaroService} from '../shared/cookies/klaro.service';
import { CookieService } from '../core/services/cookie.service'; import {CookieService} from '../core/services/cookie.service';
import { Subscription } from 'rxjs';
export const TYPE_REQUEST_FORGOT = 'forgot';
export const TYPE_REQUEST_REGISTER = 'register';
@Component({ @Component({
selector: 'ds-register-email-form', selector: 'ds-register-email-form',
@@ -24,7 +28,7 @@ import { CookieService } from '../core/services/cookie.service';
/** /**
* Component responsible to render an email registration form. * Component responsible to render an email registration form.
*/ */
export class RegisterEmailFormComponent implements OnInit { export class RegisterEmailFormComponent implements OnDestroy, OnInit {
/** /**
* The form containing the mail address * The form containing the mail address
@@ -37,6 +41,12 @@ export class RegisterEmailFormComponent implements OnInit {
@Input() @Input()
MESSAGE_PREFIX: string; MESSAGE_PREFIX: string;
/**
* Type of register request to be done, register new email or forgot password (same endpoint)
*/
@Input()
typeRequest: string = null;
public AlertTypeEnum = AlertType; public AlertTypeEnum = AlertType;
/** /**
@@ -51,6 +61,11 @@ export class RegisterEmailFormComponent implements OnInit {
disableUntilChecked = true; disableUntilChecked = true;
validMailDomains: string[];
TYPE_REQUEST_REGISTER = TYPE_REQUEST_REGISTER;
subscriptions: Subscription[] = [];
captchaVersion(): Observable<string> { captchaVersion(): Observable<string> {
return this.googleRecaptchaService.captchaVersion(); return this.googleRecaptchaService.captchaVersion();
} }
@@ -72,31 +87,54 @@ export class RegisterEmailFormComponent implements OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
) { ) {
}
ngOnDestroy(): void {
this.subscriptions.forEach((sub: Subscription) => sub.unsubscribe());
} }
ngOnInit(): void { ngOnInit(): void {
const validators: ValidatorFn[] = [
Validators.required,
Validators.email,
// Regex pattern borrowed from HTML5 specs for a valid email address:
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
Validators.pattern('^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$')
];
this.form = this.formBuilder.group({ this.form = this.formBuilder.group({
email: new FormControl('', { email: new FormControl('', {
validators: [Validators.required, validators: validators,
// Regex pattern borrowed from HTML5 specs for a valid email address:
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
Validators.pattern('^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$')
],
}) })
}); });
this.configService.findByPropertyName('registration.verification.enabled').pipe( this.validMailDomains = [];
if (this.typeRequest === TYPE_REQUEST_REGISTER) {
this.subscriptions.push(this.configService.findByPropertyName('authentication-password.domain.valid')
.pipe(getAllSucceededRemoteDataPayload())
.subscribe((remoteData: ConfigurationProperty) => {
this.validMailDomains = remoteData.values;
for (const remoteValue of remoteData.values) {
if (this.validMailDomains.length !== 0) {
this.form.get('email').setValidators([
...validators,
Validators.pattern(this.validMailDomains.map((domain: string) => '(^.*' + domain.replace(new RegExp('\\.', 'g'), '\\.') + '$)').join('|')),
]);
this.form.updateValueAndValidity();
}
}
this.changeDetectorRef.detectChanges();
}));
}
this.subscriptions.push(this.configService.findByPropertyName('registration.verification.enabled').pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
map((res: ConfigurationProperty) => res?.values[0].toLowerCase() === 'true') map((res: ConfigurationProperty) => res?.values[0].toLowerCase() === 'true')
).subscribe((res: boolean) => { ).subscribe((res: boolean) => {
this.registrationVerification = res; this.registrationVerification = res;
}); }));
this.disableUntilCheckedFcn().subscribe((res) => { this.subscriptions.push(this.disableUntilCheckedFcn().subscribe((res) => {
this.disableUntilChecked = res; this.disableUntilChecked = res;
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
}); }));
} }
/** /**
@@ -112,7 +150,7 @@ export class RegisterEmailFormComponent implements OnInit {
register(tokenV2?) { register(tokenV2?) {
if (!this.form.invalid) { if (!this.form.invalid) {
if (this.registrationVerification) { if (this.registrationVerification) {
combineLatest([this.captchaVersion(), this.captchaMode()]).pipe( this.subscriptions.push(combineLatest([this.captchaVersion(), this.captchaMode()]).pipe(
switchMap(([captchaVersion, captchaMode]) => { switchMap(([captchaVersion, captchaMode]) => {
if (captchaVersion === 'v3') { if (captchaVersion === 'v3') {
return this.googleRecaptchaService.getRecaptchaToken('register_email'); return this.googleRecaptchaService.getRecaptchaToken('register_email');
@@ -134,7 +172,7 @@ export class RegisterEmailFormComponent implements OnInit {
this.showNotification('error'); this.showNotification('error');
} }
} }
); ));
} else { } else {
this.registration(); this.registration();
} }
@@ -146,18 +184,20 @@ export class RegisterEmailFormComponent implements OnInit {
*/ */
registration(captchaToken = null) { registration(captchaToken = null) {
let registerEmail$ = captchaToken ? let registerEmail$ = captchaToken ?
this.epersonRegistrationService.registerEmail(this.email.value, captchaToken) : this.epersonRegistrationService.registerEmail(this.email.value, captchaToken, this.typeRequest) :
this.epersonRegistrationService.registerEmail(this.email.value); this.epersonRegistrationService.registerEmail(this.email.value, null, this.typeRequest);
registerEmail$.subscribe((response: RemoteData<Registration>) => { this.subscriptions.push(registerEmail$.subscribe((response: RemoteData<Registration>) => {
if (response.hasSucceeded) { if (response.hasSucceeded) {
this.notificationService.success(this.translateService.get(`${this.MESSAGE_PREFIX}.success.head`), this.notificationService.success(this.translateService.get(`${this.MESSAGE_PREFIX}.success.head`),
this.translateService.get(`${this.MESSAGE_PREFIX}.success.content`, {email: this.email.value})); this.translateService.get(`${this.MESSAGE_PREFIX}.success.content`, {email: this.email.value}));
this.router.navigate(['/home']); this.router.navigate(['/home']);
} else if (response.statusCode === 422) {
this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`), this.translateService.get(`${this.MESSAGE_PREFIX}.error.maildomain`, {domains: this.validMailDomains.join(', ')}));
} else { } else {
this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`), this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`),
this.translateService.get(`${this.MESSAGE_PREFIX}.error.content`, {email: this.email.value})); this.translateService.get(`${this.MESSAGE_PREFIX}.error.content`, {email: this.email.value}));
} }
}); }));
} }
/** /**

View File

@@ -1,3 +1,3 @@
<ds-register-email-form <ds-register-email-form
[MESSAGE_PREFIX]="'register-page.registration'"> [MESSAGE_PREFIX]="'register-page.registration'" [typeRequest]="typeRequest">
</ds-register-email-form> </ds-register-email-form>

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { TYPE_REQUEST_REGISTER } from '../../register-email-form/register-email-form.component';
@Component({ @Component({
selector: 'ds-register-email', selector: 'ds-register-email',
@@ -9,5 +10,5 @@ import { Component } from '@angular/core';
* Component responsible the email registration step when registering as a new user * Component responsible the email registration step when registering as a new user
*/ */
export class RegisterEmailComponent { export class RegisterEmailComponent {
typeRequest = TYPE_REQUEST_REGISTER;
} }

View File

@@ -1,9 +1,6 @@
input[type="text"] { input[type="text"] {
margin-top: calc(-0.5 * var(--bs-font-size-base)); margin-top: calc(-0.5 * var(--bs-font-size-base));
background-color: #fff !important;
&:focus {
background-color: rgba(255, 255, 255, 0.5) !important;
}
&.collapsed { &.collapsed {
opacity: 0; opacity: 0;

View File

@@ -0,0 +1,9 @@
<div>
<a *ngIf="(metadataRepresentation.representationType=='browse_link')"
target="_blank" class="dont-break-out"
[routerLink]="['/browse/', metadataRepresentation.browseDefinition.id]"
[queryParams]="getQueryParams()">
{{metadataRepresentation.getValue()}}
</a>
<b>(new browse link page)</b>
</div>

View File

@@ -0,0 +1,62 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { BrowseLinkMetadataListElementComponent } from './browse-link-metadata-list-element.component';
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), {
key: 'dc.contributor.author',
value: 'Test Author'
});
const mockMetadataRepresentationWithUrl = Object.assign(new MetadatumRepresentation('type'), {
key: 'dc.subject',
value: 'http://purl.org/test/subject'
});
describe('BrowseLinkMetadataListElementComponent', () => {
let comp: BrowseLinkMetadataListElementComponent;
let fixture: ComponentFixture<BrowseLinkMetadataListElementComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [],
declarations: [BrowseLinkMetadataListElementComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(BrowseLinkMetadataListElementComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(BrowseLinkMetadataListElementComponent);
comp = fixture.componentInstance;
comp.metadataRepresentation = mockMetadataRepresentation;
fixture.detectChanges();
}));
waitForAsync(() => {
it('should contain the value as a browse link', () => {
expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value);
});
it('should NOT match isLink', () => {
expect(comp.isLink).toBe(false);
});
});
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(BrowseLinkMetadataListElementComponent);
comp = fixture.componentInstance;
comp.metadataRepresentation = mockMetadataRepresentationWithUrl;
fixture.detectChanges();
}));
waitForAsync(() => {
it('should contain the value expected', () => {
expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentationWithUrl.value);
});
it('should match isLink', () => {
expect(comp.isLink).toBe(true);
});
});
});

View File

@@ -0,0 +1,29 @@
import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model';
import { Component } from '@angular/core';
import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component';
import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator';
//@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText)
// For now, authority controlled fields are rendered the same way as plain text fields
//@metadataRepresentationComponent('Publication', MetadataRepresentationType.AuthorityControlled)
@metadataRepresentationComponent('Publication', MetadataRepresentationType.BrowseLink)
@Component({
selector: 'ds-browse-link-metadata-list-element',
templateUrl: './browse-link-metadata-list-element.component.html'
})
/**
* A component for displaying MetadataRepresentation objects in the form of plain text
* It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text
*/
export class BrowseLinkMetadataListElementComponent extends MetadataRepresentationListElementComponent {
/**
* Get the appropriate query parameters for this browse link, depending on whether the browse definition
* expects 'startsWith' (eg browse by date) or 'value' (eg browse by title)
*/
getQueryParams() {
let queryParams = {startsWith: this.metadataRepresentation.getValue()};
if (this.metadataRepresentation.browseDefinition.metadataBrowse) {
return {value: this.metadataRepresentation.getValue()};
}
return queryParams;
}
}

View File

@@ -0,0 +1,59 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { mockData } from '../../testing/browse-definition-data-service.stub';
import { MetadataRepresentationListElementComponent } from './metadata-representation-list-element.component';
// Mock metadata representation values
const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type', mockData[1]), {
key: 'dc.contributor.author',
value: 'Test Author'
});
const mockMetadataRepresentationUrl = Object.assign(new MetadatumRepresentation('type', mockData[1]), {
key: 'dc.subject',
value: 'https://www.google.com'
});
describe('MetadataRepresentationListElementComponent', () => {
let comp: MetadataRepresentationListElementComponent;
let fixture: ComponentFixture<MetadataRepresentationListElementComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [],
declarations: [MetadataRepresentationListElementComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MetadataRepresentationListElementComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(MetadataRepresentationListElementComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
}));
describe('when the value is not a URL', () => {
beforeEach(() => {
comp.metadataRepresentation = mockMetadataRepresentation;
});
it('isLink correctly detects a non-URL string as false', () => {
waitForAsync(() => {
expect(comp.isLink()).toBe(false);
});
});
});
describe('when the value is a URL', () => {
beforeEach(() => {
comp.metadataRepresentation = mockMetadataRepresentationUrl;
});
it('isLink correctly detects a URL string as true', () => {
waitForAsync(() => {
expect(comp.isLink()).toBe(true);
});
});
});
});

View File

@@ -13,4 +13,14 @@ export class MetadataRepresentationListElementComponent {
* The metadata representation of this component * The metadata representation of this component
*/ */
metadataRepresentation: MetadataRepresentation; metadataRepresentation: MetadataRepresentation;
/**
* Returns true if this component's value matches a basic regex "Is this an HTTP URL" test
*/
isLink(): boolean {
// Match any string that begins with http:// or https://
const linkPattern = new RegExp(/^https?\/\/.*/);
return linkPattern.test(this.metadataRepresentation.getValue());
}
} }

View File

@@ -1,3 +1,17 @@
<div> <div>
<span class="dont-break-out">{{metadataRepresentation.getValue()}}</span> <!-- Because this template is used by default, we will additionally test for representation type and display accordingly -->
<span *ngIf="(metadataRepresentation.representationType=='plain_text') && !isLink()" class="dont-break-out">
{{metadataRepresentation.getValue()}}
</span>
<a *ngIf="(metadataRepresentation.representationType=='plain_text') && isLink()" class="dont-break-out"
target="_blank" [href]="metadataRepresentation.getValue()">
{{metadataRepresentation.getValue()}}
</a>
<span *ngIf="(metadataRepresentation.representationType=='authority_controlled')" class="dont-break-out">{{metadataRepresentation.getValue()}}</span>
<a *ngIf="(metadataRepresentation.representationType=='browse_link')"
class="dont-break-out ds-browse-link"
[routerLink]="['/browse/', metadataRepresentation.browseDefinition.id]"
[queryParams]="getQueryParams()">
{{metadataRepresentation.getValue()}}
</a>
</div> </div>

View File

@@ -2,8 +2,12 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component'; import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component';
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { By } from '@angular/platform-browser';
import { mockData } from '../../../testing/browse-definition-data-service.stub';
const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), { // Render the mock representation with the default mock author browse definition so it is also rendered as a link
// without affecting other tests
const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type', mockData[1]), {
key: 'dc.contributor.author', key: 'dc.contributor.author',
value: 'Test Author' value: 'Test Author'
}); });
@@ -33,4 +37,8 @@ describe('PlainTextMetadataListElementComponent', () => {
expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value); expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value);
}); });
it('should contain the browse link as plain text', () => {
expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockMetadataRepresentation.value);
});
}); });

View File

@@ -15,4 +15,15 @@ import { metadataRepresentationComponent } from '../../../metadata-representatio
* It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text * It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text
*/ */
export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent { export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent {
/**
* Get the appropriate query parameters for this browse link, depending on whether the browse definition
* expects 'startsWith' (eg browse by date) or 'value' (eg browse by title)
*/
getQueryParams() {
let queryParams = {startsWith: this.metadataRepresentation.getValue()};
if (this.metadataRepresentation.browseDefinition.metadataBrowse) {
return {value: this.metadataRepresentation.getValue()};
}
return queryParams;
}
} }

View File

@@ -84,6 +84,8 @@ import { LangSwitchComponent } from './lang-switch/lang-switch.component';
import { import {
PlainTextMetadataListElementComponent PlainTextMetadataListElementComponent
} from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; } from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component';
import { BrowseLinkMetadataListElementComponent }
from './object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component';
import { import {
ItemMetadataListElementComponent ItemMetadataListElementComponent
} from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component'; } from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component';
@@ -381,6 +383,7 @@ const ENTRY_COMPONENTS = [
EditItemSelectorComponent, EditItemSelectorComponent,
ThemedEditItemSelectorComponent, ThemedEditItemSelectorComponent,
PlainTextMetadataListElementComponent, PlainTextMetadataListElementComponent,
BrowseLinkMetadataListElementComponent,
ItemMetadataListElementComponent, ItemMetadataListElementComponent,
MetadataRepresentationListElementComponent, MetadataRepresentationListElementComponent,
ItemMetadataRepresentationListElementComponent, ItemMetadataRepresentationListElementComponent,

View File

@@ -0,0 +1,63 @@
import { EMPTY, Observable, of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model';
import { BrowseDefinition } from '../../core/shared/browse-definition.model';
import { BrowseService } from '../../core/browse/browse.service';
import { createSuccessfulRemoteDataObject } from '../remote-data.utils';
import { PageInfo } from '../../core/shared/page-info.model';
// This data is in post-serialized form (metadata -> metadataKeys)
export const mockData: BrowseDefinition[] = [
Object.assign(new BrowseDefinition, {
'id' : 'dateissued',
'metadataBrowse' : false,
'dataType' : 'date',
'sortOptions' : EMPTY,
'order' : 'ASC',
'type' : 'browse',
'metadataKeys' : [ 'dc.date.issued' ],
'_links' : EMPTY
}),
Object.assign(new BrowseDefinition, {
'id' : 'author',
'metadataBrowse' : true,
'dataType' : 'text',
'sortOptions' : EMPTY,
'order' : 'ASC',
'type' : 'browse',
'metadataKeys' : [ 'dc.contributor.*', 'dc.creator' ],
'_links' : EMPTY
})
];
export const BrowseDefinitionDataServiceStub: any = {
/**
* Get all BrowseDefinitions
*/
findAll(): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return observableOf(createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), mockData)));
},
/**
* Get all BrowseDefinitions with any link configuration
*/
findAllLinked(): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return observableOf(createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), mockData)));
},
/**
* Get the browse URL by providing a list of metadata keys
*
* @param metadataKeys a list of fields eg. ['dc.contributor.author', 'dc.creator']
*/
findByFields(metadataKeys: string[]): Observable<RemoteData<BrowseDefinition>> {
let searchKeyArray: string[] = [];
metadataKeys.forEach((metadataKey) => {
searchKeyArray = searchKeyArray.concat(BrowseService.toSearchKeyArray(metadataKey));
});
// Return just the first, as a pretend match
return observableOf(createSuccessfulRemoteDataObject(mockData[0]));
}
};

View File

@@ -1538,7 +1538,7 @@
"forgot-email.form.email.error.required": "Please fill in an email address", "forgot-email.form.email.error.required": "Please fill in an email address",
"forgot-email.form.email.error.pattern": "Please fill in a valid email address", "forgot-email.form.email.error.not-email-form": "Please fill in a valid email address",
"forgot-email.form.email.hint": "An email will be sent to this address with a further instructions.", "forgot-email.form.email.hint": "An email will be sent to this address with a further instructions.",
@@ -3342,7 +3342,9 @@
"register-page.registration.email.error.required": "Please fill in an email address", "register-page.registration.email.error.required": "Please fill in an email address",
"register-page.registration.email.error.pattern": "Please fill in a valid email address", "register-page.registration.email.error.not-email-form": "Please fill in a valid email address.",
"register-page.registration.email.error.not-valid-domain": "Use email with allowed domains: {{ domains }}",
"register-page.registration.email.hint": "This address will be verified and used as your login name.", "register-page.registration.email.hint": "This address will be verified and used as your login name.",
@@ -3359,6 +3361,8 @@
"register-page.registration.error.recaptcha": "Error when trying to authenticate with recaptcha", "register-page.registration.error.recaptcha": "Error when trying to authenticate with recaptcha",
"register-page.registration.google-recaptcha.must-accept-cookies": "In order to register you must accept the <b>Registration and Password recovery</b> (Google reCaptcha) cookies.", "register-page.registration.google-recaptcha.must-accept-cookies": "In order to register you must accept the <b>Registration and Password recovery</b> (Google reCaptcha) cookies.",
"register-page.registration.error.maildomain": "This email address is not on the list of domains who can register. Allowed domains are {{ domains }}",
"register-page.registration.google-recaptcha.open-cookie-settings": "Open cookie settings", "register-page.registration.google-recaptcha.open-cookie-settings": "Open cookie settings",
@@ -3367,6 +3371,7 @@
"register-page.registration.google-recaptcha.notification.message.error": "An error occurred during reCaptcha verification", "register-page.registration.google-recaptcha.notification.message.error": "An error occurred during reCaptcha verification",
"register-page.registration.google-recaptcha.notification.message.expired": "Verification expired. Please verify again.", "register-page.registration.google-recaptcha.notification.message.expired": "Verification expired. Please verify again.",
"register-page.registration.info.maildomain": "Accounts can be registered for mail addresses of the domains",
"relationships.add.error.relationship-type.content": "No suitable match could be found for relationship type {{ type }} between the two items", "relationships.add.error.relationship-type.content": "No suitable match could be found for relationship type {{ type }} between the two items",

View File

@@ -33,6 +33,8 @@ import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mo
import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service';
import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service';
import { ServerInitService } from './server-init.service'; import { ServerInitService } from './server-init.service';
import { XhrFactory } from '@angular/common';
import { ServerXhrService } from '../../app/core/services/server-xhr.service';
export function createTranslateLoader(transferState: TransferState) { export function createTranslateLoader(transferState: TransferState) {
return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json'); return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json');
@@ -104,6 +106,10 @@ export function createTranslateLoader(transferState: TransferState) {
provide: HardRedirectService, provide: HardRedirectService,
useClass: ServerHardRedirectService, useClass: ServerHardRedirectService,
}, },
{
provide: XhrFactory,
useClass: ServerXhrService,
},
] ]
}) })
export class ServerAppModule { export class ServerAppModule {