Merge pull request #2875 from alexandrevryghem/created-metadata-service-for-metadata-operations_contribute-main

Moved MetadataValue methods to separate service
This commit is contained in:
Tim Donohue
2024-05-08 15:17:19 -05:00
committed by GitHub
22 changed files with 1149 additions and 1172 deletions

View File

@@ -29,7 +29,7 @@ import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { authReducer } from './core/auth/auth.reducer'; import { authReducer } from './core/auth/auth.reducer';
import { AuthService } from './core/auth/auth.service'; import { AuthService } from './core/auth/auth.service';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { MetadataService } from './core/metadata/metadata.service'; import { HeadTagService } from './core/metadata/head-tag.service';
import { RouteService } from './core/services/route.service'; import { RouteService } from './core/services/route.service';
import { import {
NativeWindowRef, NativeWindowRef,
@@ -42,7 +42,7 @@ import { MenuService } from './shared/menu/menu.service';
import { MockActivatedRoute } from './shared/mocks/active-router.mock'; import { MockActivatedRoute } from './shared/mocks/active-router.mock';
import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.service.mock'; import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.service.mock';
import { AuthServiceMock } from './shared/mocks/auth.service.mock'; import { AuthServiceMock } from './shared/mocks/auth.service.mock';
import { MetadataServiceMock } from './shared/mocks/metadata-service.mock'; import { HeadTagServiceMock } from './shared/mocks/head-tag-service.mock';
import { RouterMock } from './shared/mocks/router.mock'; import { RouterMock } from './shared/mocks/router.mock';
import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { getMockThemeService } from './shared/mocks/theme-service.mock';
import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock';
@@ -87,7 +87,7 @@ describe('App component', () => {
], ],
providers: [ providers: [
{ provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: NativeWindowService, useValue: new NativeWindowRef() },
{ provide: MetadataService, useValue: new MetadataServiceMock() }, { provide: HeadTagService, useValue: new HeadTagServiceMock() },
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
{ provide: AuthService, useValue: new AuthServiceMock() }, { provide: AuthService, useValue: new AuthServiceMock() },
{ provide: Router, useValue: new RouterMock() }, { provide: Router, useValue: new RouterMock() },

View File

@@ -1,5 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { PAGINATED_RELATIONS_TO_ITEMS_OPERATOR } from '../../item-page/simple/item-types/shared/item-relationships-utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { import {
@@ -11,7 +15,9 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { followLink } from '../../shared/utils/follow-link-config.model'; import { followLink } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { Relationship } from '../shared/item-relationships/relationship.model'; import { Relationship } from '../shared/item-relationships/relationship.model';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
@@ -20,6 +26,7 @@ import { MetadataRepresentationType } from '../shared/metadata-representation/me
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { testSearchDataImplementation } from './base/search-data.spec'; import { testSearchDataImplementation } from './base/search-data.spec';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { ItemDataService } from './item-data.service';
import { buildPaginatedList } from './paginated-list.model'; import { buildPaginatedList } from './paginated-list.model';
import { RelationshipDataService } from './relationship-data.service'; import { RelationshipDataService } from './relationship-data.service';
import { DeleteRequest } from './request.models'; import { DeleteRequest } from './request.models';
@@ -123,18 +130,6 @@ describe('RelationshipDataService', () => {
findByHref: createSuccessfulRemoteDataObject$(relatedItems[0]), findByHref: createSuccessfulRemoteDataObject$(relatedItems[0]),
}); });
function initTestService() {
return new RelationshipDataService(
requestService,
rdbService,
halService,
objectCache as ObjectCacheService,
itemService,
null,
jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v),
);
}
const getRequestEntry$ = (successful: boolean) => { const getRequestEntry$ = (successful: boolean) => {
return observableOf({ return observableOf({
response: { isSuccessful: successful, payload: relationships } as any, response: { isSuccessful: successful, payload: relationships } as any,
@@ -143,11 +138,25 @@ describe('RelationshipDataService', () => {
beforeEach(() => { beforeEach(() => {
requestService = getMockRequestService(getRequestEntry$(true)); requestService = getMockRequestService(getRequestEntry$(true));
service = initTestService();
TestBed.configureTestingModule({
providers: [
{ provide: RequestService, useValue: requestService },
{ provide: RemoteDataBuildService, useValue: rdbService },
{ provide: HALEndpointService, useValue: halService },
{ provide: ObjectCacheService, useValue: objectCache },
{ provide: ItemDataService, useValue: itemService },
{ provide: RequestService, useValue: requestService },
{ provide: PAGINATED_RELATIONS_TO_ITEMS_OPERATOR, useValue: jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v) },
{ provide: Store, useValue: provideMockStore() },
RelationshipDataService,
],
});
service = TestBed.inject(RelationshipDataService);
}); });
describe('composition', () => { describe('composition', () => {
const initService = () => new RelationshipDataService(null, null, null, null, null, null, null); const initService = () => new RelationshipDataService(null, null, null, null, null, null, null, null);
testSearchDataImplementation(initService); testSearchDataImplementation(initService);
}); });

View File

@@ -54,6 +54,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv
import { RequestParam } from '../cache/models/request-param.model'; import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { MetadataService } from '../metadata/metadata.service';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
@@ -128,6 +129,7 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected metadataService: MetadataService,
protected itemService: ItemDataService, protected itemService: ItemDataService,
protected appStore: Store<AppState>, protected appStore: Store<AppState>,
@Inject(PAGINATED_RELATIONS_TO_ITEMS_OPERATOR) private paginatedRelationsToItems: (thisId: string) => (source: Observable<RemoteData<PaginatedList<Relationship>>>) => Observable<RemoteData<PaginatedList<Item>>>, @Inject(PAGINATED_RELATIONS_TO_ITEMS_OPERATOR) private paginatedRelationsToItems: (thisId: string) => (source: Observable<RemoteData<PaginatedList<Relationship>>>) => Observable<RemoteData<PaginatedList<Item>>>,
@@ -602,8 +604,8 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
* @param itemType The type of item this metadata value represents (will only be used when no related item can be found, as a fallback) * @param itemType The type of item this metadata value represents (will only be used when no related item can be found, as a fallback)
*/ */
resolveMetadataRepresentation(metadatum: MetadataValue, parentItem: DSpaceObject, itemType: string): Observable<MetadataRepresentation> { resolveMetadataRepresentation(metadatum: MetadataValue, parentItem: DSpaceObject, itemType: string): Observable<MetadataRepresentation> {
if (metadatum.isVirtual) { if (this.metadataService.isVirtual(metadatum)) {
return this.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe( return this.findById(this.metadataService.virtualValue(metadatum), true, false, followLink('leftItem'), followLink('rightItem')).pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
switchMap((relRD: RemoteData<Relationship>) => switchMap((relRD: RemoteData<Relationship>) =>
observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe( observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe(

View File

@@ -0,0 +1,508 @@
import {
fakeAsync,
tick,
} from '@angular/core/testing';
import {
Meta,
Title,
} from '@angular/platform-browser';
import {
NavigationEnd,
Router,
} from '@angular/router';
import { createMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
of,
} from 'rxjs';
import { AppConfig } from '../../../config/app-config.interface';
import {
ItemMock,
MockBitstream1,
MockBitstream2,
MockBitstream3,
} from '../../shared/mocks/item.mock';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import {
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$,
} from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { DSONameService } from '../breadcrumbs/dso-name.service';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { PaginatedList } from '../data/paginated-list.model';
import { RemoteData } from '../data/remote-data';
import { RootDataService } from '../data/root-data.service';
import { HardRedirectService } from '../services/hard-redirect.service';
import { Bitstream } from '../shared/bitstream.model';
import { Bundle } from '../shared/bundle.model';
import { Item } from '../shared/item.model';
import { MetadataValue } from '../shared/metadata.models';
import { HeadTagService } from './head-tag.service';
import {
AddMetaTagAction,
ClearMetaTagAction,
} from './meta-tag.actions';
describe('HeadTagService', () => {
let headTagService: HeadTagService;
let meta: Meta;
let title: Title;
let dsoNameService: DSONameService;
let bundleDataService;
let rootService: RootDataService;
let translateService: TranslateService;
let hardRedirectService: HardRedirectService;
let authorizationService: AuthorizationDataService;
let router: Router;
let store;
let appConfig: AppConfig;
const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] } } };
beforeEach(() => {
rootService = jasmine.createSpyObj({
findRoot: createSuccessfulRemoteDataObject$({ dspaceVersion: 'mock-dspace-version' }),
});
bundleDataService = jasmine.createSpyObj({
findByItemAndName: mockBundleRD$([MockBitstream3]),
});
translateService = getMockTranslateService();
meta = jasmine.createSpyObj('meta', {
addTag: {},
removeTag: {},
});
title = jasmine.createSpyObj({
setTitle: {},
});
dsoNameService = jasmine.createSpyObj({
getName: ItemMock.firstMetadataValue('dc.title'),
});
router = {
url: '/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
events: of(new NavigationEnd(1, '', '')),
routerState: {
root: {},
},
} as any as Router;
hardRedirectService = jasmine.createSpyObj( {
getCurrentOrigin: 'https://request.org',
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
store = createMockStore({ initialState });
spyOn(store, 'dispatch');
appConfig = {
item: {
bitstream: {
pageSize: 5,
},
},
} as any;
headTagService = new HeadTagService(
router,
translateService,
meta,
title,
dsoNameService,
bundleDataService,
rootService,
store,
hardRedirectService,
appConfig,
authorizationService,
);
});
it('items page should set meta tags', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(title.setTitle).toHaveBeenCalledWith('Test PowerPoint Document');
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_title',
content: 'Test PowerPoint Document',
});
expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_author', content: 'Doe, Jane' });
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_publication_date',
content: '1650-06-26',
});
expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_issn', content: '123456789' });
expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_language', content: 'en' });
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_keywords',
content: 'keyword1; keyword2; keyword3',
});
}));
it('items page should set meta tags as published Thesis', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_dissertation_name',
content: 'Test PowerPoint Document',
});
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download',
});
}));
it('items page should set meta tags as published Technical Report', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_technical_report_institution',
content: 'Mock Publisher',
});
}));
it('route titles should overwrite dso titles', fakeAsync(() => {
(translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Translated Route Title'));
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
title: 'route.title.key',
},
},
});
tick();
expect(title.setTitle).toHaveBeenCalledTimes(2);
expect((title.setTitle as jasmine.Spy).calls.argsFor(0)).toEqual(['Test PowerPoint Document']);
expect((title.setTitle as jasmine.Spy).calls.argsFor(1)).toEqual(['DSpace :: Translated Route Title']);
}));
it('other navigation should add title and description', fakeAsync(() => {
(translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!'));
(headTagService as any).processRouteChange({
data: {
value: {
title: 'Dummy Title',
description: 'This is a dummy item component for testing!',
},
},
});
tick();
expect(title.setTitle).toHaveBeenCalledWith('DSpace :: Dummy Title');
expect(meta.addTag).toHaveBeenCalledWith({
name: 'title',
content: 'DSpace :: Dummy Title',
});
expect(meta.addTag).toHaveBeenCalledWith({
name: 'description',
content: 'This is a dummy item component for testing!',
});
}));
describe(`listenForRouteChange`, () => {
it(`should call processRouteChange`, fakeAsync(() => {
spyOn(headTagService as any, 'processRouteChange').and.callFake(() => undefined);
headTagService.listenForRouteChange();
tick();
expect((headTagService as any).processRouteChange).toHaveBeenCalled();
}));
it(`should add Generator`, fakeAsync(() => {
spyOn(headTagService as any, 'processRouteChange').and.callFake(() => undefined);
headTagService.listenForRouteChange();
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'Generator',
content: 'mock-dspace-version',
});
}));
});
describe('citation_abstract_html_url', () => {
it('should use dc.identifier.uri if available', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockUri(ItemMock, 'https://ddg.gg')),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_abstract_html_url',
content: 'https://ddg.gg',
});
}));
it('should use current route as fallback', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockUri(ItemMock)),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_abstract_html_url',
content: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
});
}));
});
describe('citation_*_institution / citation_publisher', () => {
it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_dissertation_institution',
content: 'Mock Publisher',
});
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_technical_report_institution' }));
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_publisher' }));
}));
it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
},
},
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_dissertation_institution' }));
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_technical_report_institution',
content: 'Mock Publisher',
});
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_publisher' }));
}));
it('should use citation_publisher for other item types', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Some Other Type'))),
},
},
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_dissertation_institution' }));
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_technical_report_institution' }));
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_publisher',
content: 'Mock Publisher',
});
}));
});
describe('citation_pdf_url', () => {
it('should link to primary Bitstream URL regardless of format', fakeAsync(() => {
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([], MockBitstream3));
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download',
});
}));
describe('bitstream not download allowed', () => {
it('should not have citation_pdf_url', fakeAsync(() => {
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3]));
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_pdf_url' }));
}));
});
describe('no primary Bitstream', () => {
it('should link to first and only Bitstream regardless of format', fakeAsync(() => {
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3]));
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download',
});
}));
describe(`when there's a bitstream with an allowed format on the first page`, () => {
let bitstreams;
beforeEach(() => {
bitstreams = [MockBitstream2, MockBitstream3, MockBitstream1];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
});
it('should link to first Bitstream with allowed format', fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download',
});
}));
});
});
});
describe(`when there's no bitstream with an allowed format on the first page`, () => {
let bitstreams;
beforeEach(() => {
bitstreams = [MockBitstream1, MockBitstream3, MockBitstream2];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
});
it(`shouldn't add a citation_pdf_url meta tag`, fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download',
});
}));
});
describe('tagstore', () => {
beforeEach(fakeAsync(() => {
(headTagService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
}));
it('should remove previous tags on route change', fakeAsync(() => {
expect(meta.removeTag).toHaveBeenCalledWith('name=\'title\'');
expect(meta.removeTag).toHaveBeenCalledWith('name=\'description\'');
}));
it('should clear all tags and add new ones on route change', () => {
expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]);
expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]);
expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]);
});
});
const mockType = (mockItem: Item, type: string): Item => {
const typedMockItem = Object.assign(new Item(), mockItem) as Item;
typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[];
return typedMockItem;
};
const mockPublisher = (mockItem: Item): Item => {
const publishedMockItem = Object.assign(new Item(), mockItem) as Item;
publishedMockItem.metadata['dc.publisher'] = [
{
language: 'en_US',
value: 'Mock Publisher',
},
] as MetadataValue[];
return publishedMockItem;
};
const mockUri = (mockItem: Item, uri?: string): Item => {
const publishedMockItem = Object.assign(new Item(), mockItem) as Item;
publishedMockItem.metadata['dc.identifier.uri'] = [{ value: uri }] as MetadataValue[];
return publishedMockItem;
};
const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable<RemoteData<Bundle>> => {
return createSuccessfulRemoteDataObject$(
Object.assign(new Bundle(), {
name: 'ORIGINAL',
bitstreams: createSuccessfulRemoteDataObject$(mockBitstreamPages$(bitstreams)[0]),
primaryBitstream: createSuccessfulRemoteDataObject$(primary),
}),
);
};
const mockBitstreamPages$ = (bitstreams: Bitstream[]): PaginatedList<Bitstream>[] => {
return bitstreams.map((bitstream, index) => Object.assign(createPaginatedList([bitstream]), {
pageInfo: {
totalElements: bitstreams.length, // announce multiple elements/pages
},
_links: index < bitstreams.length - 1
? { next: { href: 'not empty' } } // fake link to the next bitstream page
: { next: { href: undefined } }, // last page has no link
}));
};
});

View File

@@ -0,0 +1,527 @@
import {
Inject,
Injectable,
} from '@angular/core';
import {
Meta,
MetaDefinition,
Title,
} from '@angular/platform-browser';
import {
ActivatedRoute,
NavigationEnd,
Router,
} from '@angular/router';
import {
createSelector,
select,
Store,
} from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
BehaviorSubject,
combineLatest,
concat as observableConcat,
EMPTY,
Observable,
of as observableOf,
} from 'rxjs';
import {
filter,
map,
mergeMap,
switchMap,
take,
} from 'rxjs/operators';
import {
APP_CONFIG,
AppConfig,
} from '../../../config/app-config.interface';
import { getBitstreamDownloadRoute } from '../../app-routing-paths';
import {
hasNoValue,
hasValue,
isNotEmpty,
} from '../../shared/empty.util';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { DSONameService } from '../breadcrumbs/dso-name.service';
import { coreSelector } from '../core.selectors';
import { CoreState } from '../core-state.model';
import { BundleDataService } from '../data/bundle-data.service';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { PaginatedList } from '../data/paginated-list.model';
import { RemoteData } from '../data/remote-data';
import { RootDataService } from '../data/root-data.service';
import { HardRedirectService } from '../services/hard-redirect.service';
import { Bitstream } from '../shared/bitstream.model';
import { getDownloadableBitstream } from '../shared/bitstream.operators';
import { BitstreamFormat } from '../shared/bitstream-format.model';
import { Bundle } from '../shared/bundle.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { Item } from '../shared/item.model';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import {
AddMetaTagAction,
ClearMetaTagAction,
} from './meta-tag.actions';
import { MetaTagState } from './meta-tag.reducer';
/**
* The base selector function to select the metaTag section in the store
*/
const metaTagSelector = createSelector(
coreSelector,
(state: CoreState) => state.metaTag,
);
/**
* Selector function to select the tags in use from the MetaTagState
*/
const tagsInUseSelector =
createSelector(
metaTagSelector,
(state: MetaTagState) => state.tagsInUse,
);
@Injectable({
providedIn: 'root',
})
export class HeadTagService {
private currentObject: BehaviorSubject<DSpaceObject> = new BehaviorSubject<DSpaceObject>(undefined);
/**
* When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream),
* the first Bitstream to match one of the following MIME types is selected.
* See {@linkcode getFirstAllowedFormatBitstreamLink}
* @private
*/
private readonly CITATION_PDF_URL_MIMETYPES = [
'application/pdf', // .pdf
'application/postscript', // .ps
'application/msword', // .doc
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/rtf', // .rtf
'application/epub+zip', // .epub
];
constructor(
protected router: Router,
protected translate: TranslateService,
protected meta: Meta,
protected title: Title,
protected dsoNameService: DSONameService,
protected bundleDataService: BundleDataService,
protected rootService: RootDataService,
protected store: Store<CoreState>,
protected hardRedirectService: HardRedirectService,
@Inject(APP_CONFIG) protected appConfig: AppConfig,
protected authorizationService: AuthorizationDataService,
) {
}
public listenForRouteChange(): void {
// This never changes, set it only once
this.setGenerator();
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map(() => this.router.routerState.root),
map((route: ActivatedRoute) => {
route = this.getCurrentRoute(route);
return { params: route.params, data: route.data };
})).subscribe((routeInfo: any) => {
this.processRouteChange(routeInfo);
});
}
protected processRouteChange(routeInfo: any): void {
this.clearMetaTags();
if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) {
this.currentObject.next(routeInfo.data.value.dso.payload);
this.setDSOMetaTags();
}
if (routeInfo.data.value.title) {
const titlePrefix = this.translate.get('repository.title.prefix');
const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value);
combineLatest([titlePrefix, title]).pipe(take(1)).subscribe(([translatedTitlePrefix, translatedTitle]: [string, string]) => {
this.addMetaTag('title', translatedTitlePrefix + translatedTitle);
this.title.setTitle(translatedTitlePrefix + translatedTitle);
});
}
if (routeInfo.data.value.description) {
this.translate.get(routeInfo.data.value.description).pipe(take(1)).subscribe((translatedDescription: string) => {
this.addMetaTag('description', translatedDescription);
});
}
}
protected getCurrentRoute(route: ActivatedRoute): ActivatedRoute {
while (route.firstChild) {
route = route.firstChild;
}
return route;
}
protected setDSOMetaTags(): void {
this.setTitleTag();
this.setDescriptionTag();
this.setCitationTitleTag();
this.setCitationAuthorTags();
this.setCitationPublicationDateTag();
this.setCitationISSNTag();
this.setCitationISBNTag();
this.setCitationLanguageTag();
this.setCitationKeywordsTag();
this.setCitationAbstractUrlTag();
this.setCitationPdfUrlTag();
this.setCitationPublisherTag();
if (this.isDissertation()) {
this.setCitationDissertationNameTag();
}
// this.setCitationJournalTitleTag();
// this.setCitationVolumeTag();
// this.setCitationIssueTag();
// this.setCitationFirstPageTag();
// this.setCitationLastPageTag();
// this.setCitationDOITag();
// this.setCitationPMIDTag();
// this.setCitationFullTextTag();
// this.setCitationConferenceTag();
// this.setCitationPatentCountryTag();
// this.setCitationPatentNumberTag();
}
/**
* Add <meta name="title" ... > to the <head>
*/
protected setTitleTag(): void {
const value = this.dsoNameService.getName(this.currentObject.getValue());
this.addMetaTag('title', value);
this.title.setTitle(value);
}
/**
* Add <meta name="description" ... > to the <head>
*/
protected setDescriptionTag(): void {
// TODO: truncate abstract
const value = this.getMetaTagValue('dc.description.abstract');
this.addMetaTag('description', value);
}
/**
* Add <meta name="citation_title" ... > to the <head>
*/
protected setCitationTitleTag(): void {
const value = this.getMetaTagValue('dc.title');
this.addMetaTag('citation_title', value);
}
/**
* Add <meta name="citation_author" ... > to the <head>
*/
protected setCitationAuthorTags(): void {
const values: string[] = this.getMetaTagValues(['dc.author', 'dc.contributor.author', 'dc.creator']);
this.addMetaTags('citation_author', values);
}
/**
* Add <meta name="citation_publication_date" ... > to the <head>
*/
protected setCitationPublicationDateTag(): void {
const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']);
this.addMetaTag('citation_publication_date', value);
}
/**
* Add <meta name="citation_issn" ... > to the <head>
*/
protected setCitationISSNTag(): void {
const value = this.getMetaTagValue('dc.identifier.issn');
this.addMetaTag('citation_issn', value);
}
/**
* Add <meta name="citation_isbn" ... > to the <head>
*/
protected setCitationISBNTag(): void {
const value = this.getMetaTagValue('dc.identifier.isbn');
this.addMetaTag('citation_isbn', value);
}
/**
* Add <meta name="citation_language" ... > to the <head>
*/
protected setCitationLanguageTag(): void {
const value = this.getFirstMetaTagValue(['dc.language', 'dc.language.iso']);
this.addMetaTag('citation_language', value);
}
/**
* Add <meta name="citation_dissertation_name" ... > to the <head>
*/
protected setCitationDissertationNameTag(): void {
const value = this.getMetaTagValue('dc.title');
this.addMetaTag('citation_dissertation_name', value);
}
/**
* Add dc.publisher to the <head>. The tag name depends on the item type.
*/
protected setCitationPublisherTag(): void {
const value = this.getMetaTagValue('dc.publisher');
if (this.isDissertation()) {
this.addMetaTag('citation_dissertation_institution', value);
} else if (this.isTechReport()) {
this.addMetaTag('citation_technical_report_institution', value);
} else {
this.addMetaTag('citation_publisher', value);
}
}
/**
* Add <meta name="citation_keywords" ... > to the <head>
*/
protected setCitationKeywordsTag(): void {
const value = this.getMetaTagValuesAndCombine('dc.subject');
this.addMetaTag('citation_keywords', value);
}
/**
* Add <meta name="citation_abstract_html_url" ... > to the <head>
*/
protected setCitationAbstractUrlTag(): void {
if (this.currentObject.value instanceof Item) {
let url = this.getMetaTagValue('dc.identifier.uri');
if (hasNoValue(url)) {
url = new URLCombiner(this.hardRedirectService.getCurrentOrigin(), this.router.url).toString();
}
this.addMetaTag('citation_abstract_html_url', url);
}
}
/**
* Add <meta name="citation_pdf_url" ... > to the <head>
*/
protected setCitationPdfUrlTag(): void {
if (this.currentObject.value instanceof Item) {
const item = this.currentObject.value as Item;
// Retrieve the ORIGINAL bundle for the item
this.bundleDataService.findByItemAndName(
item,
'ORIGINAL',
true,
true,
followLink('primaryBitstream'),
followLink('bitstreams', {
findListOptions: {
// limit the number of bitstreams used to find the citation pdf url to the number
// shown by default on an item page
elementsPerPage: this.appConfig.item.bitstream.pageSize,
},
}, followLink('format')),
).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((bundle: Bundle) =>
// First try the primary bitstream
bundle.primaryBitstream.pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<Bitstream>) => {
if (hasValue(rd.payload)) {
return rd.payload;
} else {
return null;
}
}),
getDownloadableBitstream(this.authorizationService),
// return the bundle as well so we can use it again if there's no primary bitstream
map((bitstream: Bitstream) => [bundle, bitstream]),
),
),
switchMap(([bundle, primaryBitstream]: [Bundle, Bitstream]) => {
if (hasValue(primaryBitstream)) {
// If there was a downloadable primary bitstream, emit its link
return [getBitstreamDownloadRoute(primaryBitstream)];
} else {
// Otherwise consider the regular bitstreams in the bundle
return bundle.bitstreams.pipe(
getFirstCompletedRemoteData(),
switchMap((bitstreamRd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRd.payload) && bitstreamRd.payload.totalElements === 1) {
// If there's only one bitstream in the bundle, emit its link if its downloadable
return this.getBitLinkIfDownloadable(bitstreamRd.payload.page[0], bitstreamRd);
} else {
// Otherwise check all bitstreams to see if one matches the format whitelist
return this.getFirstAllowedFormatBitstreamLink(bitstreamRd);
}
}),
);
}
}),
take(1),
).subscribe((link: string) => {
// Use the found link to set the <meta> tag
this.addMetaTag(
'citation_pdf_url',
new URLCombiner(this.hardRedirectService.getCurrentOrigin(), link).toString(),
);
});
}
}
getBitLinkIfDownloadable(bitstream: Bitstream, bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
return observableOf(bitstream).pipe(
getDownloadableBitstream(this.authorizationService),
switchMap((bit: Bitstream) => {
if (hasValue(bit)) {
return [getBitstreamDownloadRoute(bit)];
} else {
// Otherwise check all bitstreams to see if one matches the format whitelist
return this.getFirstAllowedFormatBitstreamLink(bitstreamRd);
}
}),
);
}
/**
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream
* with a MIME type.
*
* Note this will only check the current page (page size determined item.bitstream.pageSize in the
* config) of bitstreams for performance reasons.
* See https://github.com/DSpace/DSpace/issues/8648 for more info
*
* included in {@linkcode CITATION_PDF_URL_MIMETYPES}
* @param bitstreamRd
* @private
*/
protected getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
if (hasValue(bitstreamRd.payload) && isNotEmpty(bitstreamRd.payload.page)) {
// Retrieve the formats of all bitstreams in the page sequentially
return observableConcat(
...bitstreamRd.payload.page.map((bitstream: Bitstream) => bitstream.format.pipe(
getFirstSucceededRemoteDataPayload(),
// Keep the original bitstream, because it, not the format, is what we'll need
// for the link at the end
map((format: BitstreamFormat) => [bitstream, format]),
)),
).pipe(
// Verify that the bitstream is downloadable
mergeMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
getDownloadableBitstream(this.authorizationService),
map((bit: Bitstream) => [bit, format]),
)),
// Filter out only pairs with whitelisted formats and non-null bitstreams, null from download check
filter(([bitstream, format]: [Bitstream, BitstreamFormat]) =>
hasValue(format) && hasValue(bitstream) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
// We only need 1
take(1),
// Emit the link of the match
// tap((v) => console.log('result', v)),
map(([bitstream, format]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream)),
);
} else {
return EMPTY;
}
}
/**
* Add <meta name="Generator" ... > to the <head> containing the current DSpace version
*/
protected setGenerator(): void {
this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => {
this.meta.addTag({ name: 'Generator', content: root.dspaceVersion });
});
}
protected hasType(value: string): boolean {
return this.currentObject.value.hasMetadata('dc.type', { value: value, ignoreCase: true });
}
/**
* Returns true if this._item is a dissertation
*
* @returns {boolean}
* true if this._item has a dc.type equal to 'Thesis'
*/
protected isDissertation(): boolean {
return this.hasType('thesis');
}
/**
* Returns true if this._item is a technical report
*
* @returns {boolean}
* true if this._item has a dc.type equal to 'Technical Report'
*/
protected isTechReport(): boolean {
return this.hasType('technical report');
}
protected getMetaTagValue(key: string): string {
return this.currentObject.value.firstMetadataValue(key);
}
protected getFirstMetaTagValue(keys: string[]): string {
return this.currentObject.value.firstMetadataValue(keys);
}
protected getMetaTagValuesAndCombine(key: string): string {
return this.getMetaTagValues([key]).join('; ');
}
protected getMetaTagValues(keys: string[]): string[] {
return this.currentObject.value.allMetadataValues(keys);
}
protected addMetaTag(name: string, content: string): void {
if (content) {
const tag = { name, content } as MetaDefinition;
this.meta.addTag(tag);
this.storeTag(name);
}
}
protected addMetaTags(name: string, content: string[]): void {
for (const value of content) {
this.addMetaTag(name, value);
}
}
protected storeTag(key: string): void {
this.store.dispatch(new AddMetaTagAction(key));
}
protected clearMetaTags(): void {
this.store.pipe(
select(tagsInUseSelector),
take(1),
).subscribe((tagsInUse: string[]) => {
for (const name of tagsInUse) {
this.meta.removeTag('name=\'' + name + '\'');
}
this.store.dispatch(new ClearMetaTagAction());
});
}
}

View File

@@ -1,520 +1,16 @@
import { import { TestBed } from '@angular/core/testing';
fakeAsync,
tick,
} from '@angular/core/testing';
import {
Meta,
Title,
} from '@angular/platform-browser';
import {
NavigationEnd,
Router,
} from '@angular/router';
import { createMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
of,
} from 'rxjs';
import { AppConfig } from '../../../config/app-config.interface';
import {
ItemMock,
MockBitstream1,
MockBitstream2,
MockBitstream3,
} from '../../shared/mocks/item.mock';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import {
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$,
} from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { DSONameService } from '../breadcrumbs/dso-name.service';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { PaginatedList } from '../data/paginated-list.model';
import { RemoteData } from '../data/remote-data';
import { RootDataService } from '../data/root-data.service';
import { HardRedirectService } from '../services/hard-redirect.service';
import { Bitstream } from '../shared/bitstream.model';
import { Bundle } from '../shared/bundle.model';
import { Item } from '../shared/item.model';
import { MetadataValue } from '../shared/metadata.models';
import {
AddMetaTagAction,
ClearMetaTagAction,
} from './meta-tag.actions';
import { MetadataService } from './metadata.service'; import { MetadataService } from './metadata.service';
describe('MetadataService', () => { describe('MetadataService', () => {
let metadataService: MetadataService; let service: MetadataService;
let meta: Meta;
let title: Title;
let dsoNameService: DSONameService;
let bundleDataService;
let bitstreamDataService;
let rootService: RootDataService;
let translateService: TranslateService;
let hardRedirectService: HardRedirectService;
let authorizationService: AuthorizationDataService;
let router: Router;
let store;
let appConfig: AppConfig;
const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] } } };
beforeEach(() => { beforeEach(() => {
rootService = jasmine.createSpyObj({ TestBed.configureTestingModule({});
findRoot: createSuccessfulRemoteDataObject$({ dspaceVersion: 'mock-dspace-version' }), service = TestBed.inject(MetadataService);
});
bitstreamDataService = jasmine.createSpyObj({
findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([MockBitstream3])),
});
bundleDataService = jasmine.createSpyObj({
findByItemAndName: mockBundleRD$([MockBitstream3]),
});
translateService = getMockTranslateService();
meta = jasmine.createSpyObj('meta', {
addTag: {},
removeTag: {},
});
title = jasmine.createSpyObj({
setTitle: {},
});
dsoNameService = jasmine.createSpyObj({
getName: ItemMock.firstMetadataValue('dc.title'),
});
router = {
url: '/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
events: of(new NavigationEnd(1, '', '')),
routerState: {
root: {},
},
} as any as Router;
hardRedirectService = jasmine.createSpyObj( {
getCurrentOrigin: 'https://request.org',
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
store = createMockStore({ initialState });
spyOn(store, 'dispatch');
appConfig = {
item: {
bitstream: {
pageSize: 5,
},
},
} as any;
metadataService = new MetadataService(
router,
translateService,
meta,
title,
dsoNameService,
bundleDataService,
bitstreamDataService,
undefined,
rootService,
store,
hardRedirectService,
appConfig,
authorizationService,
);
}); });
it('items page should set meta tags', fakeAsync(() => { it('should be created', () => {
(metadataService as any).processRouteChange({ expect(service).toBeTruthy();
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(title.setTitle).toHaveBeenCalledWith('Test PowerPoint Document');
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_title',
content: 'Test PowerPoint Document',
});
expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_author', content: 'Doe, Jane' });
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_publication_date',
content: '1650-06-26',
});
expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_issn', content: '123456789' });
expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_language', content: 'en' });
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_keywords',
content: 'keyword1; keyword2; keyword3',
});
}));
it('items page should set meta tags as published Thesis', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_dissertation_name',
content: 'Test PowerPoint Document',
});
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download',
});
}));
it('items page should set meta tags as published Technical Report', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_technical_report_institution',
content: 'Mock Publisher',
});
}));
it('route titles should overwrite dso titles', fakeAsync(() => {
(translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Translated Route Title'));
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
title: 'route.title.key',
},
},
});
tick();
expect(title.setTitle).toHaveBeenCalledTimes(2);
expect((title.setTitle as jasmine.Spy).calls.argsFor(0)).toEqual(['Test PowerPoint Document']);
expect((title.setTitle as jasmine.Spy).calls.argsFor(1)).toEqual(['DSpace :: Translated Route Title']);
}));
it('other navigation should add title and description', fakeAsync(() => {
(translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!'));
(metadataService as any).processRouteChange({
data: {
value: {
title: 'Dummy Title',
description: 'This is a dummy item component for testing!',
},
},
});
tick();
expect(title.setTitle).toHaveBeenCalledWith('DSpace :: Dummy Title');
expect(meta.addTag).toHaveBeenCalledWith({
name: 'title',
content: 'DSpace :: Dummy Title',
});
expect(meta.addTag).toHaveBeenCalledWith({
name: 'description',
content: 'This is a dummy item component for testing!',
});
}));
describe(`listenForRouteChange`, () => {
it(`should call processRouteChange`, fakeAsync(() => {
spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined);
metadataService.listenForRouteChange();
tick();
expect((metadataService as any).processRouteChange).toHaveBeenCalled();
}));
it(`should add Generator`, fakeAsync(() => {
spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined);
metadataService.listenForRouteChange();
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'Generator',
content: 'mock-dspace-version',
});
}));
}); });
describe('citation_abstract_html_url', () => {
it('should use dc.identifier.uri if available', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockUri(ItemMock, 'https://ddg.gg')),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_abstract_html_url',
content: 'https://ddg.gg',
});
}));
it('should use current route as fallback', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockUri(ItemMock)),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_abstract_html_url',
content: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
});
}));
});
describe('citation_*_institution / citation_publisher', () => {
it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_dissertation_institution',
content: 'Mock Publisher',
});
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_technical_report_institution' }));
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_publisher' }));
}));
it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
},
},
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_dissertation_institution' }));
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_technical_report_institution',
content: 'Mock Publisher',
});
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_publisher' }));
}));
it('should use citation_publisher for other item types', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Some Other Type'))),
},
},
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_dissertation_institution' }));
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_technical_report_institution' }));
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_publisher',
content: 'Mock Publisher',
});
}));
});
describe('citation_pdf_url', () => {
it('should link to primary Bitstream URL regardless of format', fakeAsync(() => {
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([], MockBitstream3));
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download',
});
}));
describe('bitstream not download allowed', () => {
it('should not have citation_pdf_url', fakeAsync(() => {
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3]));
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_pdf_url' }));
}));
});
describe('no primary Bitstream', () => {
it('should link to first and only Bitstream regardless of format', fakeAsync(() => {
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3]));
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download',
});
}));
describe(`when there's a bitstream with an allowed format on the first page`, () => {
let bitstreams;
beforeEach(() => {
bitstreams = [MockBitstream2, MockBitstream3, MockBitstream1];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
});
it('should link to first Bitstream with allowed format', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download',
});
}));
});
});
});
describe(`when there's no bitstream with an allowed format on the first page`, () => {
let bitstreams;
beforeEach(() => {
bitstreams = [MockBitstream1, MockBitstream3, MockBitstream2];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
});
it(`shouldn't add a citation_pdf_url meta tag`, fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download',
});
}));
});
describe('tagstore', () => {
beforeEach(fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
},
},
});
tick();
}));
it('should remove previous tags on route change', fakeAsync(() => {
expect(meta.removeTag).toHaveBeenCalledWith('name=\'title\'');
expect(meta.removeTag).toHaveBeenCalledWith('name=\'description\'');
}));
it('should clear all tags and add new ones on route change', () => {
expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]);
expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]);
expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]);
});
});
const mockType = (mockItem: Item, type: string): Item => {
const typedMockItem = Object.assign(new Item(), mockItem) as Item;
typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[];
return typedMockItem;
};
const mockPublisher = (mockItem: Item): Item => {
const publishedMockItem = Object.assign(new Item(), mockItem) as Item;
publishedMockItem.metadata['dc.publisher'] = [
{
language: 'en_US',
value: 'Mock Publisher',
},
] as MetadataValue[];
return publishedMockItem;
};
const mockUri = (mockItem: Item, uri?: string): Item => {
const publishedMockItem = Object.assign(new Item(), mockItem) as Item;
publishedMockItem.metadata['dc.identifier.uri'] = [{ value: uri }] as MetadataValue[];
return publishedMockItem;
};
const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable<RemoteData<Bundle>> => {
return createSuccessfulRemoteDataObject$(
Object.assign(new Bundle(), {
name: 'ORIGINAL',
bitstreams: createSuccessfulRemoteDataObject$(mockBitstreamPages$(bitstreams)[0]),
primaryBitstream: createSuccessfulRemoteDataObject$(primary),
}),
);
};
const mockBitstreamPages$ = (bitstreams: Bitstream[]): PaginatedList<Bitstream>[] => {
return bitstreams.map((bitstream, index) => Object.assign(createPaginatedList([bitstream]), {
pageInfo: {
totalElements: bitstreams.length, // announce multiple elements/pages
},
_links: index < bitstreams.length - 1
? { next: { href: 'not empty' } } // fake link to the next bitstream page
: { next: { href: undefined } }, // last page has no link
}));
};
}); });

View File

@@ -1,529 +1,37 @@
import { import { Injectable } from '@angular/core';
Inject,
Injectable,
} from '@angular/core';
import {
Meta,
MetaDefinition,
Title,
} from '@angular/platform-browser';
import {
ActivatedRoute,
NavigationEnd,
Router,
} from '@angular/router';
import {
createSelector,
select,
Store,
} from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
BehaviorSubject,
combineLatest,
concat as observableConcat,
EMPTY,
Observable,
of as observableOf,
} from 'rxjs';
import {
filter,
map,
mergeMap,
switchMap,
take,
} from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util';
import { import {
APP_CONFIG, MetadataValue,
AppConfig, VIRTUAL_METADATA_PREFIX,
} from '../../../config/app-config.interface'; } from '../shared/metadata.models';
import { getBitstreamDownloadRoute } from '../../app-routing-paths';
import {
hasNoValue,
hasValue,
isNotEmpty,
} from '../../shared/empty.util';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { DSONameService } from '../breadcrumbs/dso-name.service';
import { coreSelector } from '../core.selectors';
import { CoreState } from '../core-state.model';
import { BitstreamDataService } from '../data/bitstream-data.service';
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
import { BundleDataService } from '../data/bundle-data.service';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { PaginatedList } from '../data/paginated-list.model';
import { RemoteData } from '../data/remote-data';
import { RootDataService } from '../data/root-data.service';
import { HardRedirectService } from '../services/hard-redirect.service';
import { Bitstream } from '../shared/bitstream.model';
import { getDownloadableBitstream } from '../shared/bitstream.operators';
import { BitstreamFormat } from '../shared/bitstream-format.model';
import { Bundle } from '../shared/bundle.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { Item } from '../shared/item.model';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import {
AddMetaTagAction,
ClearMetaTagAction,
} from './meta-tag.actions';
import { MetaTagState } from './meta-tag.reducer';
/** /**
* The base selector function to select the metaTag section in the store * Service for working with DSpace object metadata.
*/ */
const metaTagSelector = createSelector( @Injectable({
coreSelector, providedIn: 'root',
(state: CoreState) => state.metaTag, })
);
/**
* Selector function to select the tags in use from the MetaTagState
*/
const tagsInUseSelector =
createSelector(
metaTagSelector,
(state: MetaTagState) => state.tagsInUse,
);
@Injectable({ providedIn: 'root' })
export class MetadataService { export class MetadataService {
private currentObject: BehaviorSubject<DSpaceObject> = new BehaviorSubject<DSpaceObject>(undefined);
/** /**
* When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream), * Returns true if this Metadata authority key starts with 'virtual::'
* the first Bitstream to match one of the following MIME types is selected.
* See {@linkcode getFirstAllowedFormatBitstreamLink}
* @private
*/ */
private readonly CITATION_PDF_URL_MIMETYPES = [ public isVirtual(metadataValue: MetadataValue | undefined): boolean {
'application/pdf', // .pdf return hasValue(metadataValue?.authority) && metadataValue.authority.startsWith(VIRTUAL_METADATA_PREFIX);
'application/postscript', // .ps
'application/msword', // .doc
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/rtf', // .rtf
'application/epub+zip', // .epub
];
constructor(
private router: Router,
private translate: TranslateService,
private meta: Meta,
private title: Title,
private dsoNameService: DSONameService,
private bundleDataService: BundleDataService,
private bitstreamDataService: BitstreamDataService,
private bitstreamFormatDataService: BitstreamFormatDataService,
private rootService: RootDataService,
private store: Store<CoreState>,
private hardRedirectService: HardRedirectService,
@Inject(APP_CONFIG) private appConfig: AppConfig,
private authorizationService: AuthorizationDataService,
) {
}
public listenForRouteChange(): void {
// This never changes, set it only once
this.setGenerator();
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map(() => this.router.routerState.root),
map((route: ActivatedRoute) => {
route = this.getCurrentRoute(route);
return { params: route.params, data: route.data };
})).subscribe((routeInfo: any) => {
this.processRouteChange(routeInfo);
});
}
private processRouteChange(routeInfo: any): void {
this.clearMetaTags();
if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) {
this.currentObject.next(routeInfo.data.value.dso.payload);
this.setDSOMetaTags();
}
if (routeInfo.data.value.title) {
const titlePrefix = this.translate.get('repository.title.prefix');
const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value);
combineLatest([titlePrefix, title]).pipe(take(1)).subscribe(([translatedTitlePrefix, translatedTitle]: [string, string]) => {
this.addMetaTag('title', translatedTitlePrefix + translatedTitle);
this.title.setTitle(translatedTitlePrefix + translatedTitle);
});
}
if (routeInfo.data.value.description) {
this.translate.get(routeInfo.data.value.description).pipe(take(1)).subscribe((translatedDescription: string) => {
this.addMetaTag('description', translatedDescription);
});
}
}
private getCurrentRoute(route: ActivatedRoute): ActivatedRoute {
while (route.firstChild) {
route = route.firstChild;
}
return route;
}
private setDSOMetaTags(): void {
this.setTitleTag();
this.setDescriptionTag();
this.setCitationTitleTag();
this.setCitationAuthorTags();
this.setCitationPublicationDateTag();
this.setCitationISSNTag();
this.setCitationISBNTag();
this.setCitationLanguageTag();
this.setCitationKeywordsTag();
this.setCitationAbstractUrlTag();
this.setCitationPdfUrlTag();
this.setCitationPublisherTag();
if (this.isDissertation()) {
this.setCitationDissertationNameTag();
}
// this.setCitationJournalTitleTag();
// this.setCitationVolumeTag();
// this.setCitationIssueTag();
// this.setCitationFirstPageTag();
// this.setCitationLastPageTag();
// this.setCitationDOITag();
// this.setCitationPMIDTag();
// this.setCitationFullTextTag();
// this.setCitationConferenceTag();
// this.setCitationPatentCountryTag();
// this.setCitationPatentNumberTag();
} }
/** /**
* Add <meta name="title" ... > to the <head> * If this is a virtual Metadata, it returns everything in the authority key after 'virtual::'.
*
* Returns undefined otherwise.
*/ */
private setTitleTag(): void { public virtualValue(metadataValue: MetadataValue | undefined): string {
const value = this.dsoNameService.getName(this.currentObject.getValue()); if (this.isVirtual) {
this.addMetaTag('title', value); return metadataValue.authority.substring(metadataValue.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length);
this.title.setTitle(value);
}
/**
* Add <meta name="description" ... > to the <head>
*/
private setDescriptionTag(): void {
// TODO: truncate abstract
const value = this.getMetaTagValue('dc.description.abstract');
this.addMetaTag('description', value);
}
/**
* Add <meta name="citation_title" ... > to the <head>
*/
private setCitationTitleTag(): void {
const value = this.getMetaTagValue('dc.title');
this.addMetaTag('citation_title', value);
}
/**
* Add <meta name="citation_author" ... > to the <head>
*/
private setCitationAuthorTags(): void {
const values: string[] = this.getMetaTagValues(['dc.author', 'dc.contributor.author', 'dc.creator']);
this.addMetaTags('citation_author', values);
}
/**
* Add <meta name="citation_publication_date" ... > to the <head>
*/
private setCitationPublicationDateTag(): void {
const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']);
this.addMetaTag('citation_publication_date', value);
}
/**
* Add <meta name="citation_issn" ... > to the <head>
*/
private setCitationISSNTag(): void {
const value = this.getMetaTagValue('dc.identifier.issn');
this.addMetaTag('citation_issn', value);
}
/**
* Add <meta name="citation_isbn" ... > to the <head>
*/
private setCitationISBNTag(): void {
const value = this.getMetaTagValue('dc.identifier.isbn');
this.addMetaTag('citation_isbn', value);
}
/**
* Add <meta name="citation_language" ... > to the <head>
*/
private setCitationLanguageTag(): void {
const value = this.getFirstMetaTagValue(['dc.language', 'dc.language.iso']);
this.addMetaTag('citation_language', value);
}
/**
* Add <meta name="citation_dissertation_name" ... > to the <head>
*/
private setCitationDissertationNameTag(): void {
const value = this.getMetaTagValue('dc.title');
this.addMetaTag('citation_dissertation_name', value);
}
/**
* Add dc.publisher to the <head>. The tag name depends on the item type.
*/
private setCitationPublisherTag(): void {
const value = this.getMetaTagValue('dc.publisher');
if (this.isDissertation()) {
this.addMetaTag('citation_dissertation_institution', value);
} else if (this.isTechReport()) {
this.addMetaTag('citation_technical_report_institution', value);
} else { } else {
this.addMetaTag('citation_publisher', value); return undefined;
} }
} }
/**
* Add <meta name="citation_keywords" ... > to the <head>
*/
private setCitationKeywordsTag(): void {
const value = this.getMetaTagValuesAndCombine('dc.subject');
this.addMetaTag('citation_keywords', value);
}
/**
* Add <meta name="citation_abstract_html_url" ... > to the <head>
*/
private setCitationAbstractUrlTag(): void {
if (this.currentObject.value instanceof Item) {
let url = this.getMetaTagValue('dc.identifier.uri');
if (hasNoValue(url)) {
url = new URLCombiner(this.hardRedirectService.getCurrentOrigin(), this.router.url).toString();
}
this.addMetaTag('citation_abstract_html_url', url);
}
}
/**
* Add <meta name="citation_pdf_url" ... > to the <head>
*/
private setCitationPdfUrlTag(): void {
if (this.currentObject.value instanceof Item) {
const item = this.currentObject.value as Item;
// Retrieve the ORIGINAL bundle for the item
this.bundleDataService.findByItemAndName(
item,
'ORIGINAL',
true,
true,
followLink('primaryBitstream'),
followLink('bitstreams', {
findListOptions: {
// limit the number of bitstreams used to find the citation pdf url to the number
// shown by default on an item page
elementsPerPage: this.appConfig.item.bitstream.pageSize,
},
}, followLink('format')),
).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((bundle: Bundle) =>
// First try the primary bitstream
bundle.primaryBitstream.pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<Bitstream>) => {
if (hasValue(rd.payload)) {
return rd.payload;
} else {
return null;
}
}),
getDownloadableBitstream(this.authorizationService),
// return the bundle as well so we can use it again if there's no primary bitstream
map((bitstream: Bitstream) => [bundle, bitstream]),
),
),
switchMap(([bundle, primaryBitstream]: [Bundle, Bitstream]) => {
if (hasValue(primaryBitstream)) {
// If there was a downloadable primary bitstream, emit its link
return [getBitstreamDownloadRoute(primaryBitstream)];
} else {
// Otherwise consider the regular bitstreams in the bundle
return bundle.bitstreams.pipe(
getFirstCompletedRemoteData(),
switchMap((bitstreamRd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRd.payload) && bitstreamRd.payload.totalElements === 1) {
// If there's only one bitstream in the bundle, emit its link if its downloadable
return this.getBitLinkIfDownloadable(bitstreamRd.payload.page[0], bitstreamRd);
} else {
// Otherwise check all bitstreams to see if one matches the format whitelist
return this.getFirstAllowedFormatBitstreamLink(bitstreamRd);
}
}),
);
}
}),
take(1),
).subscribe((link: string) => {
// Use the found link to set the <meta> tag
this.addMetaTag(
'citation_pdf_url',
new URLCombiner(this.hardRedirectService.getCurrentOrigin(), link).toString(),
);
});
}
}
getBitLinkIfDownloadable(bitstream: Bitstream, bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
return observableOf(bitstream).pipe(
getDownloadableBitstream(this.authorizationService),
switchMap((bit: Bitstream) => {
if (hasValue(bit)) {
return [getBitstreamDownloadRoute(bit)];
} else {
// Otherwise check all bitstreams to see if one matches the format whitelist
return this.getFirstAllowedFormatBitstreamLink(bitstreamRd);
}
}),
);
}
/**
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream
* with a MIME type.
*
* Note this will only check the current page (page size determined item.bitstream.pageSize in the
* config) of bitstreams for performance reasons.
* See https://github.com/DSpace/DSpace/issues/8648 for more info
*
* included in {@linkcode CITATION_PDF_URL_MIMETYPES}
* @param bitstreamRd
* @private
*/
private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
if (hasValue(bitstreamRd.payload) && isNotEmpty(bitstreamRd.payload.page)) {
// Retrieve the formats of all bitstreams in the page sequentially
return observableConcat(
...bitstreamRd.payload.page.map((bitstream: Bitstream) => bitstream.format.pipe(
getFirstSucceededRemoteDataPayload(),
// Keep the original bitstream, because it, not the format, is what we'll need
// for the link at the end
map((format: BitstreamFormat) => [bitstream, format]),
)),
).pipe(
// Verify that the bitstream is downloadable
mergeMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
getDownloadableBitstream(this.authorizationService),
map((bit: Bitstream) => [bit, format]),
)),
// Filter out only pairs with whitelisted formats and non-null bitstreams, null from download check
filter(([bitstream, format]: [Bitstream, BitstreamFormat]) =>
hasValue(format) && hasValue(bitstream) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
// We only need 1
take(1),
// Emit the link of the match
// tap((v) => console.log('result', v)),
map(([bitstream ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream)),
);
} else {
return EMPTY;
}
}
/**
* Add <meta name="Generator" ... > to the <head> containing the current DSpace version
*/
private setGenerator(): void {
this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => {
this.meta.addTag({ name: 'Generator', content: root.dspaceVersion });
});
}
private hasType(value: string): boolean {
return this.currentObject.value.hasMetadata('dc.type', { value: value, ignoreCase: true });
}
/**
* Returns true if this._item is a dissertation
*
* @returns {boolean}
* true if this._item has a dc.type equal to 'Thesis'
*/
private isDissertation(): boolean {
return this.hasType('thesis');
}
/**
* Returns true if this._item is a technical report
*
* @returns {boolean}
* true if this._item has a dc.type equal to 'Technical Report'
*/
private isTechReport(): boolean {
return this.hasType('technical report');
}
private getMetaTagValue(key: string): string {
return this.currentObject.value.firstMetadataValue(key);
}
private getFirstMetaTagValue(keys: string[]): string {
return this.currentObject.value.firstMetadataValue(keys);
}
private getMetaTagValuesAndCombine(key: string): string {
return this.getMetaTagValues([key]).join('; ');
}
private getMetaTagValues(keys: string[]): string[] {
return this.currentObject.value.allMetadataValues(keys);
}
private addMetaTag(name: string, content: string): void {
if (content) {
const tag = { name, content } as MetaDefinition;
this.meta.addTag(tag);
this.storeTag(name);
}
}
private addMetaTags(name: string, content: string[]): void {
for (const value of content) {
this.addMetaTag(name, value);
}
}
private storeTag(key: string): void {
this.store.dispatch(new AddMetaTagAction(key));
}
public clearMetaTags() {
this.store.pipe(
select(tagsInUseSelector),
take(1),
).subscribe((tagsInUse: string[]) => {
for (const name of tagsInUse) {
this.meta.removeTag('name=\'' + name + '\'');
}
this.store.dispatch(new ClearMetaTagAction());
});
}
} }

View File

@@ -6,8 +6,6 @@ import {
} from 'cerialize'; } from 'cerialize';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { hasValue } from '../../shared/empty.util';
export const VIRTUAL_METADATA_PREFIX = 'virtual::'; export const VIRTUAL_METADATA_PREFIX = 'virtual::';
/** A single metadata value and its properties. */ /** A single metadata value and its properties. */
@@ -58,24 +56,6 @@ export class MetadataValue implements MetadataValueInterface {
@autoserialize @autoserialize
confidence: number; confidence: number;
/**
* Returns true if this Metadatum's authority key starts with 'virtual::'
*/
get isVirtual(): boolean {
return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX);
}
/**
* If this is a virtual Metadatum, it returns everything in the authority key after 'virtual::'.
* Returns undefined otherwise.
*/
get virtualValue(): string {
if (this.isVirtual) {
return this.authority.substring(this.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length);
} else {
return undefined;
}
}
} }
/** Constraints for matching metadata values. */ /** Constraints for matching metadata values. */

View File

@@ -1,4 +1,4 @@
<div class="d-flex flex-row ds-value-row" *ngVar="mdValue.newValue.isVirtual as isVirtual" role="row" <div class="d-flex flex-row ds-value-row" *ngVar="metadataService.isVirtual(mdValue.newValue) as isVirtual" role="row"
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)" cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }"> [ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell"> <div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">

View File

@@ -45,6 +45,7 @@ import { NotificationsService } from 'src/app/shared/notifications/notifications
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { ItemDataService } from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { MetadataService } from '../../../core/metadata/metadata.service';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { ConfidenceType } from '../../../core/shared/confidence-type'; import { ConfidenceType } from '../../../core/shared/confidence-type';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
@@ -212,14 +213,17 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
private isScrollableVocabulary$: Observable<boolean>; private isScrollableVocabulary$: Observable<boolean>;
private isSuggesterVocabulary$: Observable<boolean>; private isSuggesterVocabulary$: Observable<boolean>;
constructor(protected relationshipService: RelationshipDataService, constructor(
protected dsoNameService: DSONameService, protected relationshipService: RelationshipDataService,
protected vocabularyService: VocabularyService, protected dsoNameService: DSONameService,
protected itemService: ItemDataService, protected vocabularyService: VocabularyService,
protected cdr: ChangeDetectorRef, protected itemService: ItemDataService,
protected registryService: RegistryService, protected cdr: ChangeDetectorRef,
protected notificationsService: NotificationsService, protected registryService: RegistryService,
protected translate: TranslateService) { protected notificationsService: NotificationsService,
protected translate: TranslateService,
protected metadataService: MetadataService,
) {
} }
ngOnInit(): void { ngOnInit(): void {
@@ -231,7 +235,7 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
* Initialise potential properties of a virtual metadata value * Initialise potential properties of a virtual metadata value
*/ */
initVirtualProperties(): void { initVirtualProperties(): void {
this.mdRepresentation$ = this.mdValue.newValue.isVirtual ? this.mdRepresentation$ = this.metadataService.isVirtual(this.mdValue.newValue) ?
this.relationshipService.resolveMetadataRepresentation(this.mdValue.newValue, this.dso, 'Item') this.relationshipService.resolveMetadataRepresentation(this.mdValue.newValue, this.dso, 'Item')
.pipe( .pipe(
map((mdRepresentation: MetadataRepresentation) => map((mdRepresentation: MetadataRepresentation) =>

View File

@@ -31,7 +31,6 @@ import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { authReducer } from './core/auth/auth.reducer'; import { authReducer } from './core/auth/auth.reducer';
import { AuthService } from './core/auth/auth.service'; import { AuthService } from './core/auth/auth.service';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { MetadataService } from './core/metadata/metadata.service';
import { RouteService } from './core/services/route.service'; import { RouteService } from './core/services/route.service';
import { CorrelationIdService } from './correlation-id/correlation-id.service'; import { CorrelationIdService } from './correlation-id/correlation-id.service';
import { InitService } from './init.service'; import { InitService } from './init.service';
@@ -49,6 +48,9 @@ import createSpyObj = jasmine.createSpyObj;
import SpyObj = jasmine.SpyObj; import SpyObj = jasmine.SpyObj;
import { getTestScheduler } from 'jasmine-marbles'; import { getTestScheduler } from 'jasmine-marbles';
import { HeadTagService } from './core/metadata/head-tag.service';
import { HeadTagServiceMock } from './shared/mocks/head-tag-service.mock';
let spy: SpyObj<any>; let spy: SpyObj<any>;
@Injectable() @Injectable()
@@ -138,7 +140,7 @@ describe('InitService', () => {
let correlationIdServiceSpy; let correlationIdServiceSpy;
let dspaceTransferStateSpy; let dspaceTransferStateSpy;
let transferStateSpy; let transferStateSpy;
let metadataServiceSpy; let headTagService: HeadTagServiceMock;
let breadcrumbsServiceSpy; let breadcrumbsServiceSpy;
let menuServiceSpy; let menuServiceSpy;
@@ -164,9 +166,7 @@ describe('InitService', () => {
breadcrumbsServiceSpy = jasmine.createSpyObj('breadcrumbsServiceSpy', [ breadcrumbsServiceSpy = jasmine.createSpyObj('breadcrumbsServiceSpy', [
'listenForRouteChanges', 'listenForRouteChanges',
]); ]);
metadataServiceSpy = jasmine.createSpyObj('metadataService', [ headTagService = new HeadTagServiceMock();
'listenForRouteChange',
]);
menuServiceSpy = jasmine.createSpyObj('menuServiceSpy', [ menuServiceSpy = jasmine.createSpyObj('menuServiceSpy', [
'listenForRouteChanges', 'listenForRouteChanges',
]); ]);
@@ -190,7 +190,7 @@ describe('InitService', () => {
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: LocaleService, useValue: getMockLocaleService() }, { provide: LocaleService, useValue: getMockLocaleService() },
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
{ provide: MetadataService, useValue: metadataServiceSpy }, { provide: HeadTagService, useValue: headTagService },
{ provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy }, { provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy },
{ provide: AuthService, useValue: new AuthServiceMock() }, { provide: AuthService, useValue: new AuthServiceMock() },
{ provide: Router, useValue: new RouterMock() }, { provide: Router, useValue: new RouterMock() },
@@ -206,9 +206,9 @@ describe('InitService', () => {
describe('initRouteListeners', () => { describe('initRouteListeners', () => {
it('should call listenForRouteChanges', inject([InitService], (service) => { it('should call listenForRouteChanges', inject([InitService], (service) => {
// @ts-ignore spyOn(headTagService, 'listenForRouteChange');
service.initRouteListeners(); service.initRouteListeners();
expect(metadataServiceSpy.listenForRouteChange).toHaveBeenCalledTimes(1); expect(headTagService.listenForRouteChange).toHaveBeenCalledTimes(1);
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1); expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1); expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
})); }));

View File

@@ -38,7 +38,7 @@ import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { isAuthenticationBlocking } from './core/auth/selectors'; import { isAuthenticationBlocking } from './core/auth/selectors';
import { LAZY_DATA_SERVICES } from './core/data-services-map'; import { LAZY_DATA_SERVICES } from './core/data-services-map';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { MetadataService } from './core/metadata/metadata.service'; import { HeadTagService } from './core/metadata/head-tag.service';
import { CorrelationIdService } from './correlation-id/correlation-id.service'; import { CorrelationIdService } from './correlation-id/correlation-id.service';
import { dsDynamicFormControlMapFn } from './shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn'; import { dsDynamicFormControlMapFn } from './shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-map-fn';
import { MenuService } from './shared/menu/menu.service'; import { MenuService } from './shared/menu/menu.service';
@@ -70,7 +70,7 @@ export abstract class InitService {
protected translate: TranslateService, protected translate: TranslateService,
protected localeService: LocaleService, protected localeService: LocaleService,
protected angulartics2DSpace: Angulartics2DSpace, protected angulartics2DSpace: Angulartics2DSpace,
protected metadata: MetadataService, protected headTagService: HeadTagService,
protected breadcrumbsService: BreadcrumbsService, protected breadcrumbsService: BreadcrumbsService,
protected themeService: ThemeService, protected themeService: ThemeService,
protected menuService: MenuService, protected menuService: MenuService,
@@ -207,13 +207,13 @@ export abstract class InitService {
/** /**
* Start route-listening subscriptions * Start route-listening subscriptions
* - {@link MetadataService.listenForRouteChange} * - {@link HeadTagService.listenForRouteChange}
* - {@link BreadcrumbsService.listenForRouteChanges} * - {@link BreadcrumbsService.listenForRouteChanges}
* - {@link ThemeService.listenForRouteChanges} * - {@link ThemeService.listenForRouteChanges}
* @protected * @protected
*/ */
protected initRouteListeners(): void { protected initRouteListeners(): void {
this.metadata.listenForRouteChange(); this.headTagService.listenForRouteChange();
this.breadcrumbsService.listenForRouteChanges(); this.breadcrumbsService.listenForRouteChanges();
this.themeService.listenForRouteChanges(); this.themeService.listenForRouteChanges();
this.menuService.listenForRouteChanges(); this.menuService.listenForRouteChanges();

View File

@@ -28,12 +28,13 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/
import { ItemDataService } from '../../core/data/item-data.service'; import { ItemDataService } from '../../core/data/item-data.service';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { SignpostingDataService } from '../../core/data/signposting-data.service'; import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { MetadataService } from '../../core/metadata/metadata.service'; import { HeadTagService } from '../../core/metadata/head-tag.service';
import { LinkHeadService } from '../../core/services/link-head.service'; import { LinkHeadService } from '../../core/services/link-head.service';
import { ServerResponseService } from '../../core/services/server-response.service'; import { ServerResponseService } from '../../core/services/server-response.service';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { HeadTagServiceMock } from '../../shared/mocks/head-tag-service.mock';
import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { import {
@@ -74,13 +75,6 @@ const mockWithdrawnItem: Item = Object.assign(new Item(), {
isWithdrawn: true, isWithdrawn: true,
}); });
const metadataServiceStub = {
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
processRemoteData: () => {
},
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
};
describe('FullItemPageComponent', () => { describe('FullItemPageComponent', () => {
let comp: FullItemPageComponent; let comp: FullItemPageComponent;
let fixture: ComponentFixture<FullItemPageComponent>; let fixture: ComponentFixture<FullItemPageComponent>;
@@ -93,6 +87,7 @@ describe('FullItemPageComponent', () => {
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>; let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
let linkHeadService: jasmine.SpyObj<LinkHeadService>; let linkHeadService: jasmine.SpyObj<LinkHeadService>;
let notifyInfoService: jasmine.SpyObj<NotifyInfoService>; let notifyInfoService: jasmine.SpyObj<NotifyInfoService>;
let headTagService: HeadTagServiceMock;
const mocklink = { const mocklink = {
href: 'http://test.org', href: 'http://test.org',
@@ -143,6 +138,8 @@ describe('FullItemPageComponent', () => {
getInboxRelationLink: observableOf('http://test.org'), getInboxRelationLink: observableOf('http://test.org'),
}); });
headTagService = new HeadTagServiceMock();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({ imports: [TranslateModule.forRoot({
loader: { loader: {
@@ -153,7 +150,7 @@ describe('FullItemPageComponent', () => {
providers: [ providers: [
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: ItemDataService, useValue: {} }, { provide: ItemDataService, useValue: {} },
{ provide: MetadataService, useValue: metadataServiceStub }, { provide: HeadTagService, useValue: headTagService },
{ provide: AuthService, useValue: authService }, { provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationDataService }, { provide: AuthorizationDataService, useValue: authorizationDataService },
{ provide: ServerResponseService, useValue: serverResponseService }, { provide: ServerResponseService, useValue: serverResponseService },

View File

@@ -26,7 +26,6 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/
import { ItemDataService } from '../../core/data/item-data.service'; import { ItemDataService } from '../../core/data/item-data.service';
import { SignpostingDataService } from '../../core/data/signposting-data.service'; import { SignpostingDataService } from '../../core/data/signposting-data.service';
import { SignpostingLink } from '../../core/data/signposting-links.model'; import { SignpostingLink } from '../../core/data/signposting-links.model';
import { MetadataService } from '../../core/metadata/metadata.service';
import { import {
LinkDefinition, LinkDefinition,
LinkHeadService, LinkHeadService,
@@ -92,12 +91,6 @@ describe('ItemPageComponent', () => {
let linkHeadService: jasmine.SpyObj<LinkHeadService>; let linkHeadService: jasmine.SpyObj<LinkHeadService>;
let notifyInfoService: jasmine.SpyObj<NotifyInfoService>; let notifyInfoService: jasmine.SpyObj<NotifyInfoService>;
const mockMetadataService = {
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
processRemoteData: () => {
},
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
};
const mockRoute = Object.assign(new ActivatedRouteStub(), { const mockRoute = Object.assign(new ActivatedRouteStub(), {
data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) }), data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) }),
}); });
@@ -141,7 +134,6 @@ describe('ItemPageComponent', () => {
providers: [ providers: [
{ provide: ActivatedRoute, useValue: mockRoute }, { provide: ActivatedRoute, useValue: mockRoute },
{ provide: ItemDataService, useValue: {} }, { provide: ItemDataService, useValue: {} },
{ provide: MetadataService, useValue: mockMetadataService },
{ provide: Router, useValue: {} }, { provide: Router, useValue: {} },
{ provide: AuthService, useValue: authService }, { provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationDataService }, { provide: AuthorizationDataService, useValue: authorizationDataService },

View File

@@ -17,6 +17,7 @@ import { map } from 'rxjs/operators';
import { BrowseService } from '../../../core/browse/browse.service'; import { BrowseService } from '../../../core/browse/browse.service';
import { BrowseDefinitionDataService } from '../../../core/browse/browse-definition-data.service'; import { BrowseDefinitionDataService } from '../../../core/browse/browse-definition-data.service';
import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { MetadataService } from '../../../core/metadata/metadata.service';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { MetadataValue } from '../../../core/shared/metadata.models'; import { MetadataValue } from '../../../core/shared/metadata.models';
import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model';
@@ -76,6 +77,7 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList
constructor( constructor(
public relationshipService: RelationshipDataService, public relationshipService: RelationshipDataService,
protected browseDefinitionDataService: BrowseDefinitionDataService, protected browseDefinitionDataService: BrowseDefinitionDataService,
protected metadataService: MetadataService,
) { ) {
super(); super();
} }
@@ -101,7 +103,7 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList
.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) => { .map((metadatum: MetadataValue) => {
if (metadatum.isVirtual) { if (this.metadataService.isVirtual(metadatum)) {
return this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType); return this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType);
} else { } else {
// Check for a configured browse link and return a standard metadata representation // Check for a configured browse link and return a standard metadata representation

View File

@@ -5,46 +5,22 @@ import {
TestBed, TestBed,
} from '@angular/core/testing'; } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { import { Router } from '@angular/router';
ActivatedRoute, import { TranslateModule } from '@ngx-translate/core';
Router,
} from '@angular/router';
import { StoreModule } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import {
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { ThemedAdminSidebarComponent } from '../admin/admin-sidebar/themed-admin-sidebar.component'; import { ThemedAdminSidebarComponent } from '../admin/admin-sidebar/themed-admin-sidebar.component';
import { storeModuleConfig } from '../app.reducer';
import { ThemedBreadcrumbsComponent } from '../breadcrumbs/themed-breadcrumbs.component'; import { ThemedBreadcrumbsComponent } from '../breadcrumbs/themed-breadcrumbs.component';
import { authReducer } from '../core/auth/auth.reducer';
import { AuthService } from '../core/auth/auth.service';
import { LocaleService } from '../core/locale/locale.service';
import { MetadataService } from '../core/metadata/metadata.service';
import { RouteService } from '../core/services/route.service';
import {
NativeWindowRef,
NativeWindowService,
} from '../core/services/window.service';
import { ThemedFooterComponent } from '../footer/themed-footer.component'; import { ThemedFooterComponent } from '../footer/themed-footer.component';
import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component'; import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component';
import { HostWindowService } from '../shared/host-window.service'; import { HostWindowService } from '../shared/host-window.service';
import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component';
import { MenuService } from '../shared/menu/menu.service'; import { MenuService } from '../shared/menu/menu.service';
import { MockActivatedRoute } from '../shared/mocks/active-router.mock';
import { AngularticsProviderMock } from '../shared/mocks/angulartics-provider.service.mock';
import { AuthServiceMock } from '../shared/mocks/auth.service.mock';
import { MetadataServiceMock } from '../shared/mocks/metadata-service.mock';
import { RouterMock } from '../shared/mocks/router.mock'; import { RouterMock } from '../shared/mocks/router.mock';
import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
import { NotificationsBoardComponent } from '../shared/notifications/notifications-board/notifications-board.component'; import { NotificationsBoardComponent } from '../shared/notifications/notifications-board/notifications-board.component';
import { CSSVariableService } from '../shared/sass-helper/css-variable.service'; import { CSSVariableService } from '../shared/sass-helper/css-variable.service';
import { CSSVariableServiceStub } from '../shared/testing/css-variable-service.stub'; import { CSSVariableServiceStub } from '../shared/testing/css-variable-service.stub';
import { HostWindowServiceStub } from '../shared/testing/host-window-service.stub'; import { HostWindowServiceStub } from '../shared/testing/host-window-service.stub';
import { MenuServiceStub } from '../shared/testing/menu-service.stub'; import { MenuServiceStub } from '../shared/testing/menu-service.stub';
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banner/system-wide-alert-banner.component'; import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banner/system-wide-alert-banner.component';
import { RootComponent } from './root.component'; import { RootComponent } from './root.component';
@@ -57,29 +33,14 @@ describe('RootComponent', () => {
imports: [ imports: [
CommonModule, CommonModule,
NoopAnimationsModule, NoopAnimationsModule,
StoreModule.forRoot(authReducer, storeModuleConfig), TranslateModule.forRoot(),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}),
RootComponent, RootComponent,
], ],
providers: [ providers: [
{ provide: NativeWindowService, useValue: new NativeWindowRef() },
{ provide: MetadataService, useValue: new MetadataServiceMock() },
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
{ provide: AuthService, useValue: new AuthServiceMock() },
{ provide: Router, useValue: new RouterMock() }, { provide: Router, useValue: new RouterMock() },
{ provide: ActivatedRoute, useValue: new MockActivatedRoute() },
{ provide: MenuService, useValue: new MenuServiceStub() }, { provide: MenuService, useValue: new MenuServiceStub() },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
{ provide: LocaleService, useValue: {} },
provideMockStore({ core: { auth: { loading: false } } } as any),
RootComponent,
RouteService,
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
}) })

View File

@@ -10,7 +10,7 @@
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: { $implicit: model };"></ng-container> <ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: { $implicit: model };"></ng-container>
<!-- Should be *ngIf instead of class d-none, but that breaks the #componentViewContainer reference--> <!-- Should be *ngIf instead of class d-none, but that breaks the #componentViewContainer reference-->
<div [ngClass]="{'form-row': model.hasLanguages || isRelationship, <div [ngClass]="{'form-row': model.hasLanguages || isRelationship,
'd-none': value?.isVirtual && (model.hasSelectableMetadata || context?.index > 0)}"> 'd-none': this.metadataService.isVirtual(value) && (model.hasSelectableMetadata || context?.index > 0)}">
<div [ngClass]="getClass('grid', 'control')"> <div [ngClass]="getClass('grid', 'control')">
<div> <div>
<ng-container #componentViewContainer></ng-container> <ng-container #componentViewContainer></ng-container>
@@ -53,7 +53,7 @@
</div> </div>
</div> </div>
<ng-container *ngTemplateOutlet="endTemplate?.templateRef; context: model"></ng-container> <ng-container *ngTemplateOutlet="endTemplate?.templateRef; context: model"></ng-container>
<ng-container *ngIf="value?.isVirtual"> <ng-container *ngIf="this.metadataService.isVirtual(value)">
<ds-existing-metadata-list-element <ds-existing-metadata-list-element
*ngIf="model.hasSelectableMetadata" *ngIf="model.hasSelectableMetadata"
[reoRel]="relationshipValue$ | async" [reoRel]="relationshipValue$ | async"

View File

@@ -62,7 +62,6 @@ import {
APP_DATA_SERVICES_MAP, APP_DATA_SERVICES_MAP,
} from '../../../../../config/app-config.interface'; } from '../../../../../config/app-config.interface';
import { environment } from '../../../../../environments/environment'; import { environment } from '../../../../../environments/environment';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { RelationshipDataService } from '../../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../../core/data/relationship-data.service';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model';
@@ -71,7 +70,6 @@ import { VocabularyOptions } from '../../../../core/submission/vocabularies/mode
import { SubmissionService } from '../../../../submission/submission.service'; import { SubmissionService } from '../../../../submission/submission.service';
import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service';
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
import { FormService } from '../../form.service';
import { FormBuilderService } from '../form-builder.service'; import { FormBuilderService } from '../form-builder.service';
import { DsDynamicFormControlContainerComponent } from './ds-dynamic-form-control-container.component'; import { DsDynamicFormControlContainerComponent } from './ds-dynamic-form-control-container.component';
import { dsDynamicFormControlMapFn } from './ds-dynamic-form-control-map-fn'; import { dsDynamicFormControlMapFn } from './ds-dynamic-form-control-map-fn';
@@ -228,11 +226,9 @@ describe('DsDynamicFormControlContainerComponent test suite', () => {
{ provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() }, { provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() },
{ provide: RelationshipDataService, useValue: {} }, { provide: RelationshipDataService, useValue: {} },
{ provide: SelectableListService, useValue: {} }, { provide: SelectableListService, useValue: {} },
{ provide: ItemDataService, useValue: {} },
{ provide: Store, useValue: {} }, { provide: Store, useValue: {} },
{ provide: RelationshipDataService, useValue: {} }, { provide: RelationshipDataService, useValue: {} },
{ provide: SelectableListService, useValue: {} }, { provide: SelectableListService, useValue: {} },
{ provide: FormService, useValue: {} },
{ provide: FormBuilderService, useValue: {} }, { provide: FormBuilderService, useValue: {} },
{ provide: SubmissionService, useValue: {} }, { provide: SubmissionService, useValue: {} },
{ {
@@ -241,7 +237,6 @@ describe('DsDynamicFormControlContainerComponent test suite', () => {
findById: () => observableOf(createSuccessfulRemoteDataObject(testWSI)), findById: () => observableOf(createSuccessfulRemoteDataObject(testWSI)),
}, },
}, },
{ provide: NgZone, useValue: new NgZone({}) },
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: APP_DATA_SERVICES_MAP, useValue: {} },
{ provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn },

View File

@@ -14,7 +14,6 @@ import {
EventEmitter, EventEmitter,
Inject, Inject,
Input, Input,
NgZone,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit,
@@ -77,10 +76,10 @@ import {
AppConfig, AppConfig,
} from '../../../../../config/app-config.interface'; } from '../../../../../config/app-config.interface';
import { AppState } from '../../../../app.reducer'; import { AppState } from '../../../../app.reducer';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RelationshipDataService } from '../../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../../core/data/relationship-data.service';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { MetadataService } from '../../../../core/metadata/metadata.service';
import { Collection } from '../../../../core/shared/collection.model'; import { Collection } from '../../../../core/shared/collection.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
@@ -112,7 +111,6 @@ import { SelectableListService } from '../../../object-list/selectable-list/sele
import { SearchResult } from '../../../search/models/search-result.model'; import { SearchResult } from '../../../search/models/search-result.model';
import { followLink } from '../../../utils/follow-link-config.model'; import { followLink } from '../../../utils/follow-link-config.model';
import { itemLinksToFollow } from '../../../utils/relation-query.utils'; import { itemLinksToFollow } from '../../../utils/relation-query.utils';
import { FormService } from '../../form.service';
import { FormBuilderService } from '../form-builder.service'; import { FormBuilderService } from '../form-builder.service';
import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model';
import { RelationshipOptions } from '../models/relationship-options.model'; import { RelationshipOptions } from '../models/relationship-options.model';
@@ -202,17 +200,15 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
protected typeBindRelationService: DsDynamicTypeBindRelationService, protected typeBindRelationService: DsDynamicTypeBindRelationService,
protected translateService: TranslateService, protected translateService: TranslateService,
protected relationService: DynamicFormRelationService, protected relationService: DynamicFormRelationService,
private modalService: NgbModal, protected modalService: NgbModal,
private relationshipService: RelationshipDataService, protected relationshipService: RelationshipDataService,
private selectableListService: SelectableListService, protected selectableListService: SelectableListService,
private itemService: ItemDataService, protected store: Store<AppState>,
private zone: NgZone, protected submissionObjectService: SubmissionObjectDataService,
private store: Store<AppState>, protected ref: ChangeDetectorRef,
private submissionObjectService: SubmissionObjectDataService, protected formBuilderService: FormBuilderService,
private ref: ChangeDetectorRef, protected submissionService: SubmissionService,
private formService: FormService, protected metadataService: MetadataService,
public formBuilderService: FormBuilderService,
private submissionService: SubmissionService,
@Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(APP_CONFIG) protected appConfig: AppConfig,
@Inject(DYNAMIC_FORM_CONTROL_MAP_FN) protected dynamicFormControlFn: DynamicFormControlMapFn, @Inject(DYNAMIC_FORM_CONTROL_MAP_FN) protected dynamicFormControlFn: DynamicFormControlMapFn,
) { ) {
@@ -277,8 +273,8 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
this.value = Object.assign(new FormFieldMetadataValueObject(), this.model.value); this.value = Object.assign(new FormFieldMetadataValueObject(), this.model.value);
} }
if (hasValue(this.value) && this.value.isVirtual) { if (hasValue(this.value) && this.metadataService.isVirtual(this.value)) {
const relationship$ = this.relationshipService.findById(this.value.virtualValue, const relationship$ = this.relationshipService.findById(this.metadataService.virtualValue(this.value),
true, true,
true, true,
... itemLinksToFollow(this.fetchThumbnail)).pipe( ... itemLinksToFollow(this.fetchThumbnail)).pipe(

View File

@@ -1,5 +1,5 @@
export class MetadataServiceMock { export class HeadTagServiceMock {
// eslint-disable-next-line no-empty, @typescript-eslint/no-empty-function // eslint-disable-next-line no-empty, @typescript-eslint/no-empty-function
public listenForRouteChange(): void { public listenForRouteChange(): void {

View File

@@ -34,7 +34,7 @@ import { AuthService } from '../../app/core/auth/auth.service';
import { coreSelector } from '../../app/core/core.selectors'; import { coreSelector } from '../../app/core/core.selectors';
import { RootDataService } from '../../app/core/data/root-data.service'; import { RootDataService } from '../../app/core/data/root-data.service';
import { LocaleService } from '../../app/core/locale/locale.service'; import { LocaleService } from '../../app/core/locale/locale.service';
import { MetadataService } from '../../app/core/metadata/metadata.service'; import { HeadTagService } from '../../app/core/metadata/head-tag.service';
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
import { InitService } from '../../app/init.service'; import { InitService } from '../../app/init.service';
import { KlaroService } from '../../app/shared/cookies/klaro.service'; import { KlaroService } from '../../app/shared/cookies/klaro.service';
@@ -73,7 +73,7 @@ export class BrowserInitService extends InitService {
protected localeService: LocaleService, protected localeService: LocaleService,
protected angulartics2DSpace: Angulartics2DSpace, protected angulartics2DSpace: Angulartics2DSpace,
protected googleAnalyticsService: GoogleAnalyticsService, protected googleAnalyticsService: GoogleAnalyticsService,
protected metadata: MetadataService, protected headTagService: HeadTagService,
protected breadcrumbsService: BreadcrumbsService, protected breadcrumbsService: BreadcrumbsService,
protected klaroService: KlaroService, protected klaroService: KlaroService,
protected authService: AuthService, protected authService: AuthService,
@@ -89,7 +89,7 @@ export class BrowserInitService extends InitService {
translate, translate,
localeService, localeService,
angulartics2DSpace, angulartics2DSpace,
metadata, headTagService,
breadcrumbsService, breadcrumbsService,
themeService, themeService,
menuService, menuService,

View File

@@ -18,7 +18,7 @@ import { take } from 'rxjs/operators';
import { AppState } from '../../app/app.reducer'; import { AppState } from '../../app/app.reducer';
import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service';
import { LocaleService } from '../../app/core/locale/locale.service'; import { LocaleService } from '../../app/core/locale/locale.service';
import { MetadataService } from '../../app/core/metadata/metadata.service'; import { HeadTagService } from '../../app/core/metadata/head-tag.service';
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
import { InitService } from '../../app/init.service'; import { InitService } from '../../app/init.service';
import { MenuService } from '../../app/shared/menu/menu.service'; import { MenuService } from '../../app/shared/menu/menu.service';
@@ -44,7 +44,7 @@ export class ServerInitService extends InitService {
protected translate: TranslateService, protected translate: TranslateService,
protected localeService: LocaleService, protected localeService: LocaleService,
protected angulartics2DSpace: Angulartics2DSpace, protected angulartics2DSpace: Angulartics2DSpace,
protected metadata: MetadataService, protected headTagService: HeadTagService,
protected breadcrumbsService: BreadcrumbsService, protected breadcrumbsService: BreadcrumbsService,
protected themeService: ThemeService, protected themeService: ThemeService,
protected menuService: MenuService, protected menuService: MenuService,
@@ -56,7 +56,7 @@ export class ServerInitService extends InitService {
translate, translate,
localeService, localeService,
angulartics2DSpace, angulartics2DSpace,
metadata, headTagService,
breadcrumbsService, breadcrumbsService,
themeService, themeService,
menuService, menuService,