Merge remote-tracking branch 'atmire/mixing-text-authority-entities' into w2p-61133_Merge-345-and-master

Conflicts:
	src/app/+collection-page/collection-page.component.ts
	src/app/+collection-page/collection-page.module.ts
	src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts
	src/app/+item-page/field-components/metadata-values/metadata-values.component.ts
	src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html
	src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html
	src/app/+search-page/search-service/search.service.ts
	src/app/core/core.module.ts
	src/app/core/data/base-response-parsing.service.ts
	src/app/core/data/data.service.ts
	src/app/core/data/request.service.spec.ts
	src/app/core/metadata/metadata.service.spec.ts
	src/app/core/shared/dspace-object.model.ts
	src/app/core/shared/metadatum.model.ts
	src/app/core/shared/operators.ts
	src/app/core/shared/resource-type.ts
	src/app/shared/object-list/item-list-element/item-list-element.component.html
	src/app/shared/object-list/item-list-element/item-list-element.component.spec.ts
	src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html
	src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.spec.ts
	src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts
	src/app/shared/services/route.service.ts
	src/app/shared/shared.module.ts
This commit is contained in:
Kristof De Langhe
2019-03-15 09:59:30 +01:00
208 changed files with 6544 additions and 582 deletions

View File

@@ -10,10 +10,10 @@ module.exports = {
// The REST API server settings. // The REST API server settings.
rest: { rest: {
ssl: true, ssl: true,
host: 'dspace7.4science.it', host: 'dspace7-entities.atmire.com',
port: 443, port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/dspace-spring-rest/api' nameSpace: '/rest/api'
}, },
// Caching settings // Caching settings
cache: { cache: {

View File

@@ -12,8 +12,8 @@ describe('protractor App', () => {
expect<any>(page.getPageTitleText()).toEqual('DSpace Angular :: Home'); expect<any>(page.getPageTitleText()).toEqual('DSpace Angular :: Home');
}); });
it('should display header "Welcome to DSpace"', () => { it('should contain a news section', () => {
page.navigateTo(); page.navigateTo();
expect<any>(page.getFirstHeaderText()).toEqual('Welcome to DSpace'); expect<any>(page.getHomePageNewsText()).toBeDefined();
}); });
}); });

View File

@@ -9,11 +9,7 @@ export class ProtractorPage {
return browser.getTitle(); return browser.getTitle();
} }
getFirstPText() { getHomePageNewsText() {
return element(by.xpath('//p[1]')).getText(); return element(by.xpath('//ds-home-news')).getText();
}
getFirstHeaderText() {
return element(by.xpath('//h1[1]')).getText();
} }
} }

View File

@@ -91,12 +91,14 @@
}, },
"item": { "item": {
"page": { "page": {
"author": "Author", "author": "Authors",
"abstract": "Abstract", "abstract": "Abstract",
"date": "Date", "date": "Date",
"uri": "URI", "uri": "URI",
"files": "Files", "files": "Files",
"collections": "Collections", "collections": "Collections",
"subject": "Keywords",
"citation": "Citation",
"filesection": { "filesection": {
"download": "Download", "download": "Download",
"name": "Name:", "name": "Name:",
@@ -270,6 +272,84 @@
} }
} }
}, },
"relationships": {
"isPublicationOf": "Publications",
"isProjectOf": "Projects",
"isOrgUnitOf": "Org Units",
"isAuthorOf": "Authors",
"isPersonOf": "Authors",
"isJournalOf": "Journals",
"isSingleJournalOf": "Journal",
"isVolumeOf": "Volumes",
"isSingleVolumeOf": "Volume",
"isIssueOf": "Issues",
"isJournalIssueOf": "Journal Issue",
"isPublicationOfJournalIssue": "Articles"
},
"person": {
"page": {
"jobtitle": "Job Title",
"lastname": "Last Name",
"firstname": "First Name",
"email": "Email Address",
"orcid": "ORCID",
"birthdate": "Birth Date",
"staffid": "Staff ID",
"link": {
"full": "Show all metadata"
}
}
},
"project": {
"page": {
"status": "Status",
"id": "ID",
"expectedcompletion": "Expected Completion",
"description": "Description",
"keyword": "Keywords"
}
},
"orgunit": {
"page": {
"dateestablished": "Date established",
"city": "City",
"country": "Country",
"id": "ID",
"description": "Description"
}
},
"journal": {
"page": {
"issn": "ISSN",
"publisher": "Publisher",
"description": "Description",
"editor": "Editor-in-Chief"
}
},
"journalvolume": {
"page": {
"volume": "Volume",
"issuedate": "Issue Date",
"description": "Description"
}
},
"journalissue": {
"page": {
"number": "Number",
"issuedate": "Issue Date",
"description": "Description",
"keyword": "Keywords",
"journal-title": "Journal Title",
"journal-issn": "Journal ISSN"
}
},
"publication": {
"page": {
"journal-title": "Journal Title",
"journal-issn": "Journal ISSN",
"volume-title": "Volume Title"
}
},
"nav": { "nav": {
"browse": { "browse": {
"header": "All of DSpace" "header": "All of DSpace"
@@ -319,6 +399,24 @@
} }
}, },
"search": { "search": {
"journal": {
"title": "DSpace Angular :: Journal Search",
"results": {
"head": "Journal Search Results"
}
},
"person": {
"title": "DSpace Angular :: Person Search",
"results": {
"head": "Person Search Results"
}
},
"publication": {
"title": "DSpace Angular :: Publication Search",
"results": {
"head": "Publication Search Results"
}
},
"title": "DSpace Angular :: Search", "title": "DSpace Angular :: Search",
"description": "", "description": "",
"form": { "form": {
@@ -355,7 +453,8 @@
"f.dateIssued.min": "Start date", "f.dateIssued.min": "Start date",
"f.dateIssued.max": "End date", "f.dateIssued.max": "End date",
"f.subject": "Subject", "f.subject": "Subject",
"f.has_content_in_original_bundle": "Has files" "f.has_content_in_original_bundle": "Has files",
"f.entityType": "Item Type"
}, },
"filter": { "filter": {
"show-more": "Show more", "show-more": "Show more",
@@ -383,6 +482,10 @@
}, },
"has_content_in_original_bundle": { "has_content_in_original_bundle": {
"head": "Has files" "head": "Has files"
},
"entityType": {
"placeholder": "Item Type",
"head": "Item Type"
} }
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

View File

@@ -1,8 +1,9 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CollectionDataService } from '../core/data/collection-data.service'; import { CollectionDataService } from '../core/data/collection-data.service';
import { ItemDataService } from '../core/data/item-data.service';
import { PaginatedList } from '../core/data/paginated-list'; import { PaginatedList } from '../core/data/paginated-list';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
@@ -15,7 +16,7 @@ import { Item } from '../core/shared/item.model';
import { fadeIn, fadeInOut } from '../shared/animations/fade'; import { fadeIn, fadeInOut } from '../shared/animations/fade';
import { hasValue, isNotEmpty } from '../shared/empty.util'; import { hasValue, isNotEmpty } from '../shared/empty.util';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { filter, flatMap, map, tap } from 'rxjs/operators'; import { combineLatest, filter, first, flatMap, map } from 'rxjs/operators';
import { SearchService } from '../+search-page/search-service/search.service'; import { SearchService } from '../+search-page/search-service/search.service';
import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
import { toDSpaceObjectListRD } from '../core/shared/operators'; import { toDSpaceObjectListRD } from '../core/shared/operators';
@@ -42,7 +43,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
constructor( constructor(
private collectionDataService: CollectionDataService, private collectionDataService: CollectionDataService,
private searchService: SearchService, private itemDataService: ItemDataService,
private metadata: MetadataService, private metadata: MetadataService,
private route: ActivatedRoute private route: ActivatedRoute
) { ) {
@@ -56,7 +57,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.collectionRD$ = this.route.data.pipe( this.collectionRD$ = this.route.data.pipe(
map((data) => data.collection), map((data) => data.collection),
tap((data) => this.collectionId = data.payload.id) first()
); );
this.logoRD$ = this.collectionRD$.pipe( this.logoRD$ = this.collectionRD$.pipe(
map((rd: RemoteData<Collection>) => rd.payload), map((rd: RemoteData<Collection>) => rd.payload),
@@ -68,26 +69,33 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
this.metadata.processRemoteData(this.collectionRD$); this.metadata.processRemoteData(this.collectionRD$);
const page = +params.page || this.paginationConfig.currentPage; const page = +params.page || this.paginationConfig.currentPage;
const pageSize = +params.pageSize || this.paginationConfig.pageSize; const pageSize = +params.pageSize || this.paginationConfig.pageSize;
const sortDirection = +params.page || this.sortConfig.direction;
const pagination = Object.assign({}, const pagination = Object.assign({},
this.paginationConfig, this.paginationConfig,
{ currentPage: page, pageSize: pageSize } { currentPage: page, pageSize: pageSize }
); );
this.updatePage({ const sort = Object.assign({},
pagination: pagination, this.sortConfig,
sort: this.sortConfig { direction: sortDirection, field: params.sortField }
);
this.collectionRD$.subscribe((rd: RemoteData<Collection>) => {
this.collectionId = rd.payload.id;
this.updatePage({
pagination: pagination,
sort: sort
});
}); });
})); })
);
} }
updatePage(searchOptions) { updatePage(searchOptions) {
this.itemRD$ = this.searchService.search( this.itemRD$ = this.itemDataService.findAll({
new PaginatedSearchOptions({ scopeID: this.collectionId,
scope: this.collectionId, currentPage: searchOptions.pagination.currentPage,
pagination: searchOptions.pagination, elementsPerPage: searchOptions.pagination.pageSize,
sort: searchOptions.sort, sort: searchOptions.sort
dsoType: DSpaceObjectType.ITEM });
})).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>;
} }
ngOnDestroy(): void { ngOnDestroy(): void {

View File

@@ -7,15 +7,14 @@ import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageRoutingModule } from './collection-page-routing.module'; import { CollectionPageRoutingModule } from './collection-page-routing.module';
import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component';
import { CollectionFormComponent } from './collection-form/collection-form.component'; import { CollectionFormComponent } from './collection-form/collection-form.component';
import { SearchPageModule } from '../+search-page/search-page.module';
import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component'; import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
import { SearchService } from '../+search-page/search-service/search.service';
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
SharedModule, SharedModule,
SearchPageModule,
CollectionPageRoutingModule CollectionPageRoutingModule
], ],
declarations: [ declarations: [
@@ -24,6 +23,9 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c
EditCollectionPageComponent, EditCollectionPageComponent,
DeleteCollectionPageComponent, DeleteCollectionPageComponent,
CollectionFormComponent CollectionFormComponent
],
providers: [
SearchService
] ]
}) })
export class CollectionPageModule { export class CollectionPageModule {

View File

@@ -1,4 +1,4 @@
<div class="simple-view-element" [class.d-none]="content.textContent.trim().length === 0"> <div class="simple-view-element" [class.d-none]="content.textContent.trim().length === 0 && hasNoValue(content.querySelector('img'))">
<h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5> <h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5>
<div #content class="simple-view-element-body"> <div #content class="simple-view-element-body">
<ng-content></ng-content> <ng-content></ng-content>

View File

@@ -1,18 +1,41 @@
import { ComponentFixture, TestBed, async } from '@angular/core/testing'; import { Component } from '@angular/core';
import { By } from '@angular/platform-browser'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement } from '@angular/core';
import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component'; import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component';
/* tslint:disable:max-classes-per-file */
@Component({ @Component({
selector: 'ds-component-with-content', selector: 'ds-component-without-content',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' + template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
' <div class="my-content">\n' +
' <span></span>\n' +
' </div>\n' +
'</ds-metadata-field-wrapper>' '</ds-metadata-field-wrapper>'
}) })
class ContentComponent {} class NoContentComponent {}
@Component({
selector: 'ds-component-with-empty-spans',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
' <span></span>\n' +
' <span></span>\n' +
'</ds-metadata-field-wrapper>'
})
class SpanContentComponent {}
@Component({
selector: 'ds-component-with-text',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
' <span>The quick brown fox jumps over the lazy dog</span>\n' +
'</ds-metadata-field-wrapper>'
})
class TextContentComponent {}
@Component({
selector: 'ds-component-with-image',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
' <img src="https://some/image.png" alt="an alt text">\n' +
'</ds-metadata-field-wrapper>'
})
class ImgContentComponent {}
/* tslint:enable:max-classes-per-file */
describe('MetadataFieldWrapperComponent', () => { describe('MetadataFieldWrapperComponent', () => {
let component: MetadataFieldWrapperComponent; let component: MetadataFieldWrapperComponent;
@@ -20,7 +43,7 @@ describe('MetadataFieldWrapperComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [MetadataFieldWrapperComponent, ContentComponent] declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent, ImgContentComponent]
}).compileComponents(); }).compileComponents();
})); }));
@@ -30,23 +53,21 @@ describe('MetadataFieldWrapperComponent', () => {
}); });
const wrapperSelector = '.simple-view-element'; const wrapperSelector = '.simple-view-element';
const labelSelector = '.simple-view-element-header';
const contentSelector = '.my-content';
it('should create', () => { it('should create', () => {
expect(component).toBeDefined(); expect(component).toBeDefined();
}); });
it('should not show the component when there is no content', () => { it('should not show the component when there is no content', () => {
component.label = 'test label'; const parentFixture = TestBed.createComponent(NoContentComponent);
fixture.detectChanges(); parentFixture.detectChanges();
const parentNative = fixture.nativeElement; const parentNative = parentFixture.nativeElement;
const nativeWrapper = parentNative.querySelector(wrapperSelector); const nativeWrapper = parentNative.querySelector(wrapperSelector);
expect(nativeWrapper.classList.contains('d-none')).toBe(true); expect(nativeWrapper.classList.contains('d-none')).toBe(true);
}); });
it('should not show the component when there is DOM content but no text', () => { it('should not show the component when there is DOM content but not text or an image', () => {
const parentFixture = TestBed.createComponent(ContentComponent); const parentFixture = TestBed.createComponent(SpanContentComponent);
parentFixture.detectChanges(); parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement; const parentNative = parentFixture.nativeElement;
const nativeWrapper = parentNative.querySelector(wrapperSelector); const nativeWrapper = parentNative.querySelector(wrapperSelector);
@@ -54,11 +75,18 @@ describe('MetadataFieldWrapperComponent', () => {
}); });
it('should show the component when there is text content', () => { it('should show the component when there is text content', () => {
const parentFixture = TestBed.createComponent(ContentComponent); const parentFixture = TestBed.createComponent(TextContentComponent);
parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement;
const nativeWrapper = parentNative.querySelector(wrapperSelector);
parentFixture.detectChanges();
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
});
it('should show the component when there is img content', () => {
const parentFixture = TestBed.createComponent(ImgContentComponent);
parentFixture.detectChanges(); parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement; const parentNative = parentFixture.nativeElement;
const nativeContent = parentNative.querySelector(contentSelector);
nativeContent.textContent = 'lorem ipsum';
const nativeWrapper = parentNative.querySelector(wrapperSelector); const nativeWrapper = parentNative.querySelector(wrapperSelector);
parentFixture.detectChanges(); parentFixture.detectChanges();
expect(nativeWrapper.classList.contains('d-none')).toBe(false); expect(nativeWrapper.classList.contains('d-none')).toBe(false);

View File

@@ -1,4 +1,5 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { hasNoValue } from '../../../shared/empty.util';
/** /**
* This component renders any content inside this wrapper. * This component renders any content inside this wrapper.
@@ -11,6 +12,15 @@ import { Component, Input } from '@angular/core';
}) })
export class MetadataFieldWrapperComponent { export class MetadataFieldWrapperComponent {
/**
* The label (title) for the content
*/
@Input() label: string; @Input() label: string;
/**
* Make hasNoValue() available in the template
*/
hasNoValue(o: any): boolean {
return hasNoValue(o);
}
} }

View File

@@ -0,0 +1,97 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader';
import { By } from '@angular/platform-browser';
import { MetadataUriValuesComponent } from './metadata-uri-values.component';
import { isNotEmpty } from '../../../shared/empty.util';
let comp: MetadataUriValuesComponent;
let fixture: ComponentFixture<MetadataUriValuesComponent>;
const mockMetadata = [
{
key: 'dc.identifier.uri',
language: 'en_US',
value: 'http://fakelink.org'
},
{
key: 'dc.identifier.uri',
language: 'en_US',
value: 'http://another.fakelink.org'
}];
const mockSeperator = '<br/>';
const mockLabel = 'fake.message';
const mockLinkText = 'fake link text';
describe('MetadataUriValuesComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [MetadataUriValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MetadataUriValuesComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(MetadataUriValuesComponent);
comp = fixture.componentInstance;
comp.values = mockMetadata;
comp.separator = mockSeperator;
comp.label = mockLabel;
fixture.detectChanges();
}));
it('should display all metadata values', () => {
const innerHTML = fixture.nativeElement.innerHTML;
for (const metadatum of mockMetadata) {
expect(innerHTML).toContain(metadatum.value);
}
});
it('should contain the correct hrefs', () => {
const links = fixture.debugElement.queryAll(By.css('a'));
for (const metadatum of mockMetadata) {
expect(containsHref(links, metadatum.value)).toBeTruthy();
}
});
it('should contain separators equal to the amount of metadata values minus one', () => {
const separators = fixture.debugElement.queryAll(By.css('a span'));
expect(separators.length).toBe(mockMetadata.length - 1);
});
describe('when linktext is defined', () => {
beforeEach(() => {
comp.linktext = mockLinkText;
fixture.detectChanges();
});
it('should replace the metadata value with the linktext', () => {
const link = fixture.debugElement.query(By.css('a'));
expect(link.nativeElement.textContent).toContain(mockLinkText);
});
});
});
function containsHref(links: DebugElement[], href: string): boolean {
for (const link of links) {
const hrefAtt = link.properties.href;
if (isNotEmpty(hrefAtt)) {
if (hrefAtt === href) {
return true;
}
}
}
return false;
}

View File

@@ -17,11 +17,24 @@ import { MetadataValue } from '../../../core/shared/metadata.models';
}) })
export class MetadataUriValuesComponent extends MetadataValuesComponent { export class MetadataUriValuesComponent extends MetadataValuesComponent {
/**
* Optional text to replace the links with
* If undefined, the metadata value (uri) is displayed
*/
@Input() linktext: any; @Input() linktext: any;
/**
* The metadata values to display
*/
@Input() mdValues: MetadataValue[]; @Input() mdValues: MetadataValue[];
/**
* The seperator used to split the metadata values (can contain HTML)
*/
@Input() separator: string; @Input() separator: string;
/**
* The label for this iteration of metadata values
*/
@Input() label: string; @Input() label: string;
} }

View File

@@ -0,0 +1,68 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader';
import { MetadataValuesComponent } from './metadata-values.component';
import { By } from '@angular/platform-browser';
import { Metadatum } from '../../../core/shared/metadatum.model';
let comp: MetadataValuesComponent;
let fixture: ComponentFixture<MetadataValuesComponent>;
const mockMetadata = [
{
key: 'journal.identifier.issn',
language: 'en_US',
value: '1234'
},
{
key: 'journal.publisher',
language: 'en_US',
value: 'a publisher'
},
{
key: 'journal.identifier.description',
language: 'en_US',
value: 'desc'
}] as Metadatum[];
const mockSeperator = '<br/>';
const mockLabel = 'fake.message';
describe('MetadataValuesComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MetadataValuesComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(MetadataValuesComponent);
comp = fixture.componentInstance;
comp.values = mockMetadata;
comp.separator = mockSeperator;
comp.label = mockLabel;
fixture.detectChanges();
}));
it('should display all metadata values', () => {
const innerHTML = fixture.nativeElement.innerHTML;
for (const metadatum of mockMetadata) {
expect(innerHTML).toContain(metadatum.value);
}
});
it('should contain separators equal to the amount of metadata values minus one', () => {
const separators = fixture.debugElement.queryAll(By.css('span>span'));
expect(separators.length).toBe(mockMetadata.length - 1);
});
});

View File

@@ -12,10 +12,19 @@ import { MetadataValue } from '../../../core/shared/metadata.models';
}) })
export class MetadataValuesComponent { export class MetadataValuesComponent {
/**
* The metadata values to display
*/
@Input() mdValues: MetadataValue[]; @Input() mdValues: MetadataValue[];
/**
* The seperator used to split the metadata values (can contain HTML)
*/
@Input() separator: string; @Input() separator: string;
/**
* The label for this iteration of metadata values
*/
@Input() label: string; @Input() label: string;
} }

View File

@@ -0,0 +1,76 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ItemDataService } from '../../core/data/item-data.service';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { TruncatePipe } from '../../shared/utils/truncate.pipe';
import { FullItemPageComponent } from './full-item-page.component';
import { MetadataService } from '../../core/metadata/metadata.service';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { VarDirective } from '../../shared/utils/var.directive';
import { RouterTestingModule } from '@angular/router/testing';
import { Item } from '../../core/shared/item.model';
import { PageInfo } from '../../core/shared/page-info.model';
import { PaginatedList } from '../../core/data/paginated-list';
import { RemoteData } from '../../core/data/remote-data';
import { of as observableOf } from 'rxjs';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'test item'
}]
});
const routeStub = Object.assign(new ActivatedRouteStub(), {
data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) })
});
const metadataServiceStub = {
/* tslint:disable:no-empty */
processRemoteData: () => {}
/* tslint:enable:no-empty */
};
describe('FullItemPageComponent', () => {
let comp: FullItemPageComponent;
let fixture: ComponentFixture<FullItemPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
}), RouterTestingModule.withRoutes([]), BrowserAnimationsModule],
declarations: [FullItemPageComponent, TruncatePipe, VarDirective],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: ItemDataService, useValue: {}},
{provide: MetadataService, useValue: metadataServiceStub}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(FullItemPageComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(FullItemPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
}));
it('should display the item\'s metadata', () => {
const table = fixture.debugElement.query(By.css('table'));
for (const metadatum of mockItem.metadata) {
expect(table.nativeElement.innerHTML).toContain(metadatum.value);
}
})
});

View File

@@ -1,9 +1,8 @@
import {filter, map} from 'rxjs/operators'; import {filter, map} from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable , BehaviorSubject } from 'rxjs';
import { ItemPageComponent } from '../simple/item-page.component'; import { ItemPageComponent } from '../simple/item-page.component';
import { MetadataMap } from '../../core/shared/metadata.models'; import { MetadataMap } from '../../core/shared/metadata.models';
@@ -32,7 +31,7 @@ import { hasValue } from '../../shared/empty.util';
}) })
export class FullItemPageComponent extends ItemPageComponent implements OnInit { export class FullItemPageComponent extends ItemPageComponent implements OnInit {
itemRD$: Observable<RemoteData<Item>>; itemRD$: BehaviorSubject<RemoteData<Item>>;
metadata$: Observable<MetadataMap>; metadata$: Observable<MetadataMap>;

View File

@@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SharedModule } from './../shared/shared.module'; import { SharedModule } from './../shared/shared.module';
import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component';
import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageComponent } from './simple/item-page.component';
import { ItemPageRoutingModule } from './item-page-routing.module'; import { ItemPageRoutingModule } from './item-page-routing.module';
@@ -13,19 +14,32 @@ import { ItemPageDateFieldComponent } from './simple/field-components/specific-f
import { ItemPageAbstractFieldComponent } from './simple/field-components/specific-field/abstract/item-page-abstract-field.component'; import { ItemPageAbstractFieldComponent } from './simple/field-components/specific-field/abstract/item-page-abstract-field.component';
import { ItemPageUriFieldComponent } from './simple/field-components/specific-field/uri/item-page-uri-field.component'; import { ItemPageUriFieldComponent } from './simple/field-components/specific-field/uri/item-page-uri-field.component';
import { ItemPageTitleFieldComponent } from './simple/field-components/specific-field/title/item-page-title-field.component'; import { ItemPageTitleFieldComponent } from './simple/field-components/specific-field/title/item-page-title-field.component';
import { ItemPageSpecificFieldComponent } from './simple/field-components/specific-field/item-page-specific-field.component'; import { ItemPageFieldComponent } from './simple/field-components/specific-field/item-page-field.component';
import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { FileSectionComponent } from './simple/field-components/file-section/file-section.component';
import { CollectionsComponent } from './field-components/collections/collections.component'; import { CollectionsComponent } from './field-components/collections/collections.component';
import { FullItemPageComponent } from './full/full-item-page.component'; import { FullItemPageComponent } from './full/full-item-page.component';
import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component'; import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component';
import { RelatedItemsComponent } from './simple/related-items/related-items-component';
import { SearchPageModule } from '../+search-page/search-page.module';
import { PublicationComponent } from './simple/item-types/publication/publication.component';
import { PersonComponent } from './simple/item-types/person/person.component';
import { OrgunitComponent } from './simple/item-types/orgunit/orgunit.component';
import { ProjectComponent } from './simple/item-types/project/project.component';
import { JournalComponent } from './simple/item-types/journal/journal.component';
import { JournalVolumeComponent } from './simple/item-types/journal-volume/journal-volume.component';
import { JournalIssueComponent } from './simple/item-types/journal-issue/journal-issue.component';
import { ItemComponent } from './simple/item-types/shared/item.component';
import { EditItemPageModule } from './edit-item-page/edit-item-page.module'; import { EditItemPageModule } from './edit-item-page/edit-item-page.module';
import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component';
import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component';
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
SharedModule, SharedModule,
EditItemPageModule, EditItemPageModule,
ItemPageRoutingModule ItemPageRoutingModule,
SearchPageModule
], ],
declarations: [ declarations: [
ItemPageComponent, ItemPageComponent,
@@ -38,10 +52,31 @@ import { EditItemPageModule } from './edit-item-page/edit-item-page.module';
ItemPageAbstractFieldComponent, ItemPageAbstractFieldComponent,
ItemPageUriFieldComponent, ItemPageUriFieldComponent,
ItemPageTitleFieldComponent, ItemPageTitleFieldComponent,
ItemPageSpecificFieldComponent, ItemPageFieldComponent,
FileSectionComponent, FileSectionComponent,
CollectionsComponent, CollectionsComponent,
FullFileSectionComponent FullFileSectionComponent,
PublicationComponent,
ProjectComponent,
OrgunitComponent,
PersonComponent,
RelatedItemsComponent,
ItemComponent,
GenericItemPageFieldComponent,
JournalComponent,
JournalIssueComponent,
JournalVolumeComponent,
MetadataRepresentationListComponent,
RelatedEntitiesSearchComponent
],
entryComponents: [
PublicationComponent,
ProjectComponent,
OrgunitComponent,
PersonComponent,
JournalComponent,
JournalIssueComponent,
JournalVolumeComponent
] ]
}) })
export class ItemPageModule { export class ItemPageModule {

View File

@@ -5,6 +5,7 @@ import { RemoteData } from '../core/data/remote-data';
import { getSucceededRemoteData } from '../core/shared/operators'; import { getSucceededRemoteData } from '../core/shared/operators';
import { ItemDataService } from '../core/data/item-data.service'; import { ItemDataService } from '../core/data/item-data.service';
import { Item } from '../core/shared/item.model'; import { Item } from '../core/shared/item.model';
import { tap } from 'rxjs/operators';
/** /**
* This class represents a resolver that requests a specific item before the route is activated * This class represents a resolver that requests a specific item before the route is activated

View File

@@ -0,0 +1,41 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ItemPageAbstractFieldComponent } from './item-page-abstract-field.component';
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
let comp: ItemPageAbstractFieldComponent;
let fixture: ComponentFixture<ItemPageAbstractFieldComponent>;
const mockField = 'dc.description.abstract';
const mockValue = 'test value';
describe('ItemPageAbstractFieldComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [ItemPageAbstractFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemPageAbstractFieldComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(ItemPageAbstractFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
fixture.detectChanges();
}));
it('should display display the correct metadata value', () => {
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
});
});

View File

@@ -1,22 +1,39 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({ @Component({
selector: 'ds-item-page-abstract-field', selector: 'ds-item-page-abstract-field',
templateUrl: './../item-page-specific-field.component.html' templateUrl: '../item-page-field.component.html'
}) })
export class ItemPageAbstractFieldComponent extends ItemPageSpecificFieldComponent { /**
* This component is used for displaying the abstract (dc.description.abstract) of an item
*/
export class ItemPageAbstractFieldComponent extends ItemPageFieldComponent {
/**
* The item to display metadata for
*/
@Input() item: Item; @Input() item: Item;
/**
* Separator string between multiple values of the metadata fields defined
* @type {string}
*/
separator: string; separator: string;
/**
* Fields (schema.element.qualifier) used to render their values.
* In this component, we want to display values for metadata 'dc.description.abstract'
*/
fields: string[] = [ fields: string[] = [
'dc.description.abstract' 'dc.description.abstract'
]; ];
/**
* Label i18n key for the rendered metadata
*/
label = 'item.page.abstract'; label = 'item.page.abstract';
} }

View File

@@ -0,0 +1,45 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { ItemPageAuthorFieldComponent } from './item-page-author-field.component';
let comp: ItemPageAuthorFieldComponent;
let fixture: ComponentFixture<ItemPageAuthorFieldComponent>;
const mockFields = ['dc.contributor.author', 'dc.creator', 'dc.contributor'];
const mockValue = 'test value';
describe('ItemPageAuthorFieldComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemPageAuthorFieldComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
for (const field of mockFields) {
beforeEach(async(() => {
fixture = TestBed.createComponent(ItemPageAuthorFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(field, mockValue);
fixture.detectChanges();
}));
describe(`when the item contains metadata for ${field}`, () => {
it('should display display the correct metadata value', () => {
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
});
});
}
});

View File

@@ -1,24 +1,41 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({ @Component({
selector: 'ds-item-page-author-field', selector: 'ds-item-page-author-field',
templateUrl: './../item-page-specific-field.component.html' templateUrl: '../item-page-field.component.html'
}) })
export class ItemPageAuthorFieldComponent extends ItemPageSpecificFieldComponent { /**
* This component is used for displaying the author (dc.contributor.author, dc.creator and dc.contributor) metadata of an item
*/
export class ItemPageAuthorFieldComponent extends ItemPageFieldComponent {
/**
* The item to display metadata for
*/
@Input() item: Item; @Input() item: Item;
/**
* Separator string between multiple values of the metadata fields defined
* @type {string}
*/
separator: string; separator: string;
/**
* Fields (schema.element.qualifier) used to render their values.
* In this component, we want to display values for metadata 'dc.contributor.author', 'dc.creator' and 'dc.contributor'
*/
fields: string[] = [ fields: string[] = [
'dc.contributor.author', 'dc.contributor.author',
'dc.creator', 'dc.creator',
'dc.contributor' 'dc.contributor'
]; ];
/**
* Label i18n key for the rendered metadata
*/
label = 'item.page.author'; label = 'item.page.author';
} }

View File

@@ -0,0 +1,41 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { ItemPageDateFieldComponent } from './item-page-date-field.component';
let comp: ItemPageDateFieldComponent;
let fixture: ComponentFixture<ItemPageDateFieldComponent>;
const mockField = 'dc.date.issued';
const mockValue = 'test value';
describe('ItemPageDateFieldComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [ItemPageDateFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemPageDateFieldComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(ItemPageDateFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
fixture.detectChanges();
}));
it('should display display the correct metadata value', () => {
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
});
});

View File

@@ -1,22 +1,39 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({ @Component({
selector: 'ds-item-page-date-field', selector: 'ds-item-page-date-field',
templateUrl: './../item-page-specific-field.component.html' templateUrl: '../item-page-field.component.html'
}) })
export class ItemPageDateFieldComponent extends ItemPageSpecificFieldComponent { /**
* This component is used for displaying the issue date (dc.date.issued) metadata of an item
*/
export class ItemPageDateFieldComponent extends ItemPageFieldComponent {
/**
* The item to display metadata for
*/
@Input() item: Item; @Input() item: Item;
/**
* Separator string between multiple values of the metadata fields defined
* @type {string}
*/
separator = ', '; separator = ', ';
/**
* Fields (schema.element.qualifier) used to render their values.
* In this component, we want to display values for metadata 'dc.date.issued'
*/
fields: string[] = [ fields: string[] = [
'dc.date.issued' 'dc.date.issued'
]; ];
/**
* Label i18n key for the rendered metadata
*/
label = 'item.page.date'; label = 'item.page.date';
} }

View File

@@ -0,0 +1,45 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { GenericItemPageFieldComponent } from './generic-item-page-field.component';
let comp: GenericItemPageFieldComponent;
let fixture: ComponentFixture<GenericItemPageFieldComponent>;
const mockValue = 'test value';
const mockField = 'dc.test';
const mockLabel = 'test label';
const mockFields = [mockField];
describe('GenericItemPageFieldComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [GenericItemPageFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(GenericItemPageFieldComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(GenericItemPageFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
comp.fields = mockFields;
comp.label = mockLabel;
fixture.detectChanges();
}));
it('should display display the correct metadata value', () => {
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
});
});

View File

@@ -0,0 +1,38 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model';
import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({
selector: 'ds-generic-item-page-field',
templateUrl: '../item-page-field.component.html'
})
/**
* This component can be used to represent metadata on a simple item page.
* It is the most generic way of displaying metadata values
* It expects 4 parameters: The item, a seperator, the metadata keys and an i18n key
*/
export class GenericItemPageFieldComponent extends ItemPageFieldComponent {
/**
* The item to display metadata for
*/
@Input() item: Item;
/**
* Separator string between multiple values of the metadata fields defined
* @type {string}
*/
@Input() separator: string;
/**
* Fields (schema.element.qualifier) used to render their values.
*/
@Input() fields: string[];
/**
* Label i18n key for the rendered metadata
*/
@Input() label: string;
}

View File

@@ -1,3 +1,3 @@
<div class="item-page-specific-field"> <div class="item-page-field">
<ds-metadata-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-values> <ds-metadata-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-values>
</div> </div>

View File

@@ -0,0 +1,62 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Item } from '../../../../core/shared/item.model';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader';
import { Observable } from 'rxjs';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { ItemPageFieldComponent } from './item-page-field.component';
import { MetadataValuesComponent } from '../../../field-components/metadata-values/metadata-values.component';
import { of as observableOf } from 'rxjs';
let comp: ItemPageFieldComponent;
let fixture: ComponentFixture<ItemPageFieldComponent>;
const mockValue = 'test value';
const mockField = 'dc.test';
const mockLabel = 'test label';
const mockFields = [mockField];
describe('ItemPageFieldComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [ItemPageFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemPageFieldComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(ItemPageFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
comp.fields = mockFields;
comp.label = mockLabel;
fixture.detectChanges();
}));
it('should display display the correct metadata value', () => {
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
});
});
export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item {
return Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [
{
key: field,
language: 'en_US',
value: value
}]
});
}

View File

@@ -9,10 +9,13 @@ import { Item } from '../../../../core/shared/item.model';
*/ */
@Component({ @Component({
templateUrl: './item-page-specific-field.component.html' templateUrl: './item-page-field.component.html'
}) })
export class ItemPageSpecificFieldComponent { export class ItemPageFieldComponent {
/**
* The item to display metadata for
*/
@Input() item: Item; @Input() item: Item;
/** /**

View File

@@ -0,0 +1,41 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { ItemPageTitleFieldComponent } from './item-page-title-field.component';
let comp: ItemPageTitleFieldComponent;
let fixture: ComponentFixture<ItemPageTitleFieldComponent>;
const mockField = 'dc.title';
const mockValue = 'test value';
describe('ItemPageTitleFieldComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [ItemPageTitleFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemPageTitleFieldComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(ItemPageTitleFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
fixture.detectChanges();
}));
it('should display display the correct metadata value', () => {
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
});
});

View File

@@ -1,18 +1,32 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({ @Component({
selector: 'ds-item-page-title-field', selector: 'ds-item-page-title-field',
templateUrl: './item-page-title-field.component.html' templateUrl: './item-page-title-field.component.html'
}) })
export class ItemPageTitleFieldComponent extends ItemPageSpecificFieldComponent { /**
* This component is used for displaying the title (dc.title) of an item
*/
export class ItemPageTitleFieldComponent extends ItemPageFieldComponent {
/**
* The item to display metadata for
*/
@Input() item: Item; @Input() item: Item;
/**
* Separator string between multiple values of the metadata fields defined
* @type {string}
*/
separator: string; separator: string;
/**
* Fields (schema.element.qualifier) used to render their values.
* In this component, we want to display values for metadata 'dc.title'
*/
fields: string[] = [ fields: string[] = [
'dc.title' 'dc.title'
]; ];

View File

@@ -1,3 +1,3 @@
<div class="item-page-specific-field"> <div class="item-page-field">
<ds-metadata-uri-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-uri-values> <ds-metadata-uri-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-uri-values>
</div> </div>

View File

@@ -0,0 +1,41 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { ItemPageUriFieldComponent } from './item-page-uri-field.component';
import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component';
let comp: ItemPageUriFieldComponent;
let fixture: ComponentFixture<ItemPageUriFieldComponent>;
const mockField = 'dc.identifier.uri';
const mockValue = 'test value';
describe('ItemPageUriFieldComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [ItemPageUriFieldComponent, MetadataUriValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemPageUriFieldComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(ItemPageUriFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
fixture.detectChanges();
}));
it('should display display the correct metadata value', () => {
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
});
});

View File

@@ -1,22 +1,39 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; import { ItemPageFieldComponent } from '../item-page-field.component';
@Component({ @Component({
selector: 'ds-item-page-uri-field', selector: 'ds-item-page-uri-field',
templateUrl: './item-page-uri-field.component.html' templateUrl: './item-page-uri-field.component.html'
}) })
export class ItemPageUriFieldComponent extends ItemPageSpecificFieldComponent { /**
* This component is used for displaying the uri (dc.identifier.uri) metadata of an item
*/
export class ItemPageUriFieldComponent extends ItemPageFieldComponent {
/**
* The item to display metadata for
*/
@Input() item: Item; @Input() item: Item;
/**
* Separator string between multiple values of the metadata fields defined
* @type {string}
*/
separator: string; separator: string;
/**
* Fields (schema.element.qualifier) used to render their values.
* In this component, we want to display values for metadata 'dc.identifier.uri'
*/
fields: string[] = [ fields: string[] = [
'dc.identifier.uri' 'dc.identifier.uri'
]; ];
/**
* Label i18n key for the rendered metadata
*/
label = 'item.page.uri'; label = 'item.page.uri';
} }

View File

@@ -1,27 +1,7 @@
<div class="container" *ngVar="(itemRD$ | async) as itemRD"> <div class="container" *ngVar="(itemRD$ | async) as itemRD">
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut> <div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
<div *ngIf="itemRD?.payload as item"> <div *ngIf="itemRD?.payload as item">
<ds-item-page-title-field [item]="item"></ds-item-page-title-field> <ds-item-type-switcher [object]="item" [viewMode]="viewMode"></ds-item-type-switcher>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-item-page-file-section [item]="item"></ds-item-page-file-section>
<ds-item-page-date-field [item]="item"></ds-item-page-date-field>
<ds-item-page-author-field [item]="item"></ds-item-page-author-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-item-page-abstract-field [item]="item"></ds-item-page-abstract-field>
<ds-item-page-uri-field [item]="item"></ds-item-page-uri-field>
<ds-item-page-collections [item]="item"></ds-item-page-collections>
<div>
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']">
{{"item.page.link.full" | translate}}
</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error> <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>

View File

@@ -1 +1,9 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
@include media-breakpoint-down(md) {
.container {
width: 100%;
max-width: none;
}
}

View File

@@ -0,0 +1,90 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader';
import { ItemDataService } from '../../core/data/item-data.service';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ItemPageComponent } from './item-page.component';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { MetadataService } from '../../core/metadata/metadata.service';
import { VarDirective } from '../../shared/utils/var.directive';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { PaginatedList } from '../../core/data/paginated-list';
import { PageInfo } from '../../core/shared/page-info.model';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';
import { createRelationshipsObservable } from './item-types/shared/item.component.spec';
import { of as observableOf } from 'rxjs';
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [],
relationships: createRelationshipsObservable()
});
describe('ItemPageComponent', () => {
let comp: ItemPageComponent;
let fixture: ComponentFixture<ItemPageComponent>;
const mockMetadataService = {
/* tslint:disable:no-empty */
processRemoteData: () => {}
/* tslint:enable:no-empty */
};
const mockRoute = Object.assign(new ActivatedRouteStub(), {
data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) })
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
}), BrowserAnimationsModule],
declarations: [ItemPageComponent, VarDirective],
providers: [
{provide: ActivatedRoute, useValue: mockRoute},
{provide: ItemDataService, useValue: {}},
{provide: MetadataService, useValue: mockMetadataService}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemPageComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(ItemPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
}));
describe('when the item is loading', () => {
beforeEach(() => {
comp.itemRD$ = observableOf(new RemoteData(true, true, true, null, undefined));
fixture.detectChanges();
});
it('should display a loading component', () => {
const loading = fixture.debugElement.query(By.css('ds-loading'));
expect(loading.nativeElement).toBeDefined();
});
});
describe('when the item failed loading', () => {
beforeEach(() => {
comp.itemRD$ = observableOf(new RemoteData(false, false, false, null, undefined));
fixture.detectChanges();
});
it('should display an error component', () => {
const error = fixture.debugElement.query(By.css('ds-error'));
expect(error.nativeElement).toBeDefined();
});
});
});

View File

@@ -1,5 +1,4 @@
import { filter, map, mergeMap } from 'rxjs/operators';
import {mergeMap, filter, map} from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
@@ -14,6 +13,9 @@ import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade'; import { fadeInOut } from '../../shared/animations/fade';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import * as viewMode from '../../shared/view-mode';
export const VIEW_MODE_FULL = 'full';
/** /**
* This component renders a simple item page. * This component renders a simple item page.
@@ -29,21 +31,31 @@ import { hasValue } from '../../shared/empty.util';
}) })
export class ItemPageComponent implements OnInit { export class ItemPageComponent implements OnInit {
/**
* The item's id
*/
id: number; id: number;
private sub: any; /**
* The item wrapped in a remote-data object
*/
itemRD$: Observable<RemoteData<Item>>; itemRD$: Observable<RemoteData<Item>>;
/**
* The item's thumbnail
*/
thumbnail$: Observable<Bitstream>; thumbnail$: Observable<Bitstream>;
/**
* The view-mode we're currently on
*/
viewMode = VIEW_MODE_FULL;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private items: ItemDataService, private items: ItemDataService,
private metadataService: MetadataService private metadataService: MetadataService,
) { ) { }
}
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(map((data) => data.item)); this.itemRD$ = this.route.data.pipe(map((data) => data.item));

View File

@@ -0,0 +1,50 @@
<h2 class="item-page-title-field">
<ds-metadata-values [values]="item?.filterMetadata(['dc.title'])"></ds-metadata-values>
</h2>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="this.item.getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="item"
[fields]="['journalissue.identifier.number']"
[label]="'journalissue.page.number'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journalissue.issuedate']"
[label]="'journalissue.page.issuedate'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journal.title']"
[label]="'journalissue.page.journal-title'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journal.identifier.issn']"
[label]="'journalissue.page.journal-issn'">
</ds-generic-item-page-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-related-items
[items]="volumes$ | async"
[label]="'relationships.isSingleVolumeOf' | translate">
</ds-related-items>
<ds-related-items
class="mb-1 mt-1"
[items]="publications$ | async"
[label]="'relationships.isPublicationOfJournalIssue' | translate">
</ds-related-items>
<ds-generic-item-page-field [item]="item"
[fields]="['journalissue.identifier.description']"
[label]="'journalissue.page.description'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journalissue.identifier.keyword']"
[label]="'journalissue.page.keyword'">
</ds-generic-item-page-field>
<div>
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']">
{{"item.page.link.full" | translate}}
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../../../styles/variables.scss';

View File

@@ -0,0 +1,35 @@
import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
import { JournalIssueComponent } from './journal-issue.component';
import { of as observableOf } from 'rxjs';
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [
{
key: 'journalissue.identifier.number',
language: 'en_US',
value: '1234'
},
{
key: 'journalissue.issuedate',
language: 'en_US',
value: '2018'
},
{
key: 'journalissue.identifier.description',
language: 'en_US',
value: 'desc'
},
{
key: 'journalissue.identifier.keyword',
language: 'en_US',
value: 'keyword'
}],
relationships: createRelationshipsObservable()
});
describe('JournalIssueComponent', getItemPageFieldsTest(mockItem, JournalIssueComponent));

View File

@@ -0,0 +1,51 @@
import { Component, Inject } from '@angular/core';
import { Observable } from 'rxjs';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model';
import { rendersItemType } from '../../../../shared/items/item-type-decorator';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { isNotEmpty } from '../../../../shared/empty.util';
import { ItemComponent, filterRelationsByTypeLabel, relationsToItems } from '../shared/item.component';
import { VIEW_MODE_FULL } from '../../item-page.component';
@rendersItemType('JournalIssue', VIEW_MODE_FULL)
@Component({
selector: 'ds-journal-issue',
styleUrls: ['./journal-issue.component.scss'],
templateUrl: './journal-issue.component.html'
})
/**
* The component for displaying metadata and relations of an item of the type Journal Issue
*/
export class JournalIssueComponent extends ItemComponent {
/**
* The volumes related to this journal issue
*/
volumes$: Observable<Item[]>;
/**
* The publications related to this journal issue
*/
publications$: Observable<Item[]>;
constructor(
@Inject(ITEM) public item: Item,
private ids: ItemDataService
) {
super(item);
}
ngOnInit(): void {
super.ngOnInit();
if (isNotEmpty(this.resolvedRelsAndTypes$)) {
this.volumes$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isJournalVolumeOfIssue'),
relationsToItems(this.item.id, this.ids)
);
this.publications$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isPublicationOfJournalIssue'),
relationsToItems(this.item.id, this.ids)
);
}
}
}

View File

@@ -0,0 +1,37 @@
<h2 class="item-page-title-field">
<ds-metadata-values [values]="item?.filterMetadata(['dc.title'])"></ds-metadata-values>
</h2>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="this.item.getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="item"
[fields]="['journalvolume.identifier.volume']"
[label]="'journalvolume.page.volume'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journalvolume.issuedate']"
[label]="'journalvolume.page.issuedate'">
</ds-generic-item-page-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-related-items
[items]="journals$ | async"
[label]="'relationships.isSingleJournalOf' | translate">
</ds-related-items>
<ds-related-items
[items]="issues$ | async"
[label]="'relationships.isIssueOf' | translate">
</ds-related-items>
<ds-generic-item-page-field [item]="item"
[fields]="['journalvolume.identifier.description']"
[label]="'journalvolume.page.description'">
</ds-generic-item-page-field>
<div>
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']">
{{"item.page.link.full" | translate}}
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../../../styles/variables.scss';

View File

@@ -0,0 +1,30 @@
import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
import { JournalVolumeComponent } from './journal-volume.component';
import { of as observableOf } from 'rxjs';
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [
{
key: 'journalvolume.identifier.volume',
language: 'en_US',
value: '1234'
},
{
key: 'journalvolume.issuedate',
language: 'en_US',
value: '2018'
},
{
key: 'journalvolume.identifier.description',
language: 'en_US',
value: 'desc'
}],
relationships: createRelationshipsObservable()
});
describe('JournalVolumeComponent', getItemPageFieldsTest(mockItem, JournalVolumeComponent));

View File

@@ -0,0 +1,51 @@
import { Component, Inject } from '@angular/core';
import { Observable } from 'rxjs';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model';
import { rendersItemType } from '../../../../shared/items/item-type-decorator';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { isNotEmpty } from '../../../../shared/empty.util';
import { ItemComponent, filterRelationsByTypeLabel, relationsToItems } from '../shared/item.component';
import { VIEW_MODE_FULL } from '../../item-page.component';
@rendersItemType('JournalVolume', VIEW_MODE_FULL)
@Component({
selector: 'ds-journal-volume',
styleUrls: ['./journal-volume.component.scss'],
templateUrl: './journal-volume.component.html'
})
/**
* The component for displaying metadata and relations of an item of the type Journal Volume
*/
export class JournalVolumeComponent extends ItemComponent {
/**
* The journals related to this journal volume
*/
journals$: Observable<Item[]>;
/**
* The journal issues related to this journal volume
*/
issues$: Observable<Item[]>;
constructor(
@Inject(ITEM) public item: Item,
private ids: ItemDataService
) {
super(item);
}
ngOnInit(): void {
super.ngOnInit();
if (isNotEmpty(this.resolvedRelsAndTypes$)) {
this.journals$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isJournalOfVolume'),
relationsToItems(this.item.id, this.ids)
);
this.issues$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isIssueOfJournalVolume'),
relationsToItems(this.item.id, this.ids)
);
}
}
}

View File

@@ -0,0 +1,42 @@
<h2 class="item-page-title-field">
<ds-metadata-values [values]="item?.filterMetadata(['dc.title'])"></ds-metadata-values>
</h2>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="this.item.getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-generic-item-page-field class="item-page-fields" [item]="item"
[fields]="['journal.identifier.issn']"
[label]="'journal.page.issn'">
</ds-generic-item-page-field>
<ds-generic-item-page-field class="item-page-fields" [item]="item"
[fields]="['journal.publisher']"
[label]="'journal.page.publisher'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journal.contributor.editor']"
[label]="'journal.page.editor'">
</ds-generic-item-page-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-related-items
[items]="volumes$ | async"
[label]="'relationships.isVolumeOf' | translate">
</ds-related-items>
<ds-generic-item-page-field class="item-page-fields" [item]="item"
[fields]="['journal.identifier.description']"
[label]="'journal.page.description'">
</ds-generic-item-page-field>
<div>
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']">
{{"item.page.link.full" | translate}}
</a>
</div>
</div>
<div class="mt-5 w-100">
<ds-related-entities-search [item]="item"
[relationType]="'isJournalOfPublication'">
</ds-related-entities-search>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../../../styles/variables.scss';

View File

@@ -0,0 +1,88 @@
import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model';
import { By } from '@angular/platform-browser';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { isNotEmpty } from '../../../../shared/empty.util';
import { JournalComponent } from './journal.component';
import { of as observableOf } from 'rxjs';
let comp: JournalComponent;
let fixture: ComponentFixture<JournalComponent>;
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [
{
key: 'journal.identifier.issn',
language: 'en_US',
value: '1234'
},
{
key: 'journal.publisher',
language: 'en_US',
value: 'a publisher'
},
{
key: 'journal.identifier.description',
language: 'en_US',
value: 'desc'
}]
});
describe('JournalComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe],
providers: [
{provide: ITEM, useValue: mockItem},
{provide: ItemDataService, useValue: {}},
{provide: TruncatableService, useValue: {}}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(JournalComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(JournalComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
}));
for (const metadata of mockItem.metadata) {
it(`should be calling a component with metadata field ${metadata.key}`, () => {
const fields = fixture.debugElement.queryAll(By.css('.item-page-fields'));
expect(containsFieldInput(fields, metadata.key)).toBeTruthy();
});
}
});
function containsFieldInput(fields: DebugElement[], metadataKey: string): boolean {
for (const field of fields) {
const fieldComp = field.componentInstance;
if (isNotEmpty(fieldComp.fields)) {
if (fieldComp.fields.indexOf(metadataKey) > -1) {
return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,42 @@
import { Component, Inject } from '@angular/core';
import { Observable } from 'rxjs';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model';
import { rendersItemType } from '../../../../shared/items/item-type-decorator';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { isNotEmpty } from '../../../../shared/empty.util';
import { ItemComponent, filterRelationsByTypeLabel, relationsToItems } from '../shared/item.component';
import { VIEW_MODE_FULL } from '../../item-page.component';
@rendersItemType('Journal', VIEW_MODE_FULL)
@Component({
selector: 'ds-journal',
styleUrls: ['./journal.component.scss'],
templateUrl: './journal.component.html'
})
/**
* The component for displaying metadata and relations of an item of the type Journal
*/
export class JournalComponent extends ItemComponent {
/**
* The volumes related to this journal
*/
volumes$: Observable<Item[]>;
constructor(
@Inject(ITEM) public item: Item,
private ids: ItemDataService
) {
super(item);
}
ngOnInit(): void {
super.ngOnInit();
if (isNotEmpty(this.resolvedRelsAndTypes$)) {
this.volumes$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isVolumeOfJournal'),
relationsToItems(this.item.id, this.ids)
);
}
}
}

View File

@@ -0,0 +1,49 @@
<h2 class="item-page-title-field">
<ds-metadata-values [values]="item?.filterMetadata(['orgunit.identifier.name'])"></ds-metadata-values>
</h2>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="this.item.getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.jpg'"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="item"
[fields]="['orgunit.identifier.dateestablished']"
[label]="'orgunit.page.dateestablished'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['orgunit.identifier.city']"
[label]="'orgunit.page.city'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['orgunit.identifier.country']"
[label]="'orgunit.page.country'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['orgunit.identifier.id']"
[label]="'orgunit.page.id'">
</ds-generic-item-page-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-related-items
[items]="people$ | async"
[label]="'relationships.isPersonOf' | translate">
</ds-related-items>
<ds-related-items
[items]="projects$ | async"
[label]="'relationships.isProjectOf' | translate">
</ds-related-items>
<ds-related-items
[items]="publications$ | async"
[label]="'relationships.isPublicationOf' | translate">
</ds-related-items>
<ds-generic-item-page-field [item]="item"
[fields]="['orgunit.identifier.description']"
[label]="'orgunit.page.description'">
</ds-generic-item-page-field>
<div>
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']">
{{"item.page.link.full" | translate}}
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../../../styles/variables.scss';

View File

@@ -0,0 +1,40 @@
import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
import { OrgunitComponent } from './orgunit.component';
import { of as observableOf } from 'rxjs';
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [
{
key: 'orgunit.identifier.dateestablished',
language: 'en_US',
value: '2018'
},
{
key: 'orgunit.identifier.city',
language: 'en_US',
value: 'New York'
},
{
key: 'orgunit.identifier.country',
language: 'en_US',
value: 'USA'
},
{
key: 'orgunit.identifier.id',
language: 'en_US',
value: '1'
},
{
key: 'orgunit.identifier.description',
language: 'en_US',
value: 'desc'
}],
relationships: createRelationshipsObservable()
});
describe('OrgUnitComponent', getItemPageFieldsTest(mockItem, OrgunitComponent));

View File

@@ -0,0 +1,62 @@
import { Component, Inject, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model';
import { rendersItemType } from '../../../../shared/items/item-type-decorator';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { isNotEmpty } from '../../../../shared/empty.util';
import { ItemComponent, filterRelationsByTypeLabel, relationsToItems } from '../shared/item.component';
import { VIEW_MODE_FULL } from '../../item-page.component';
@rendersItemType('OrgUnit', VIEW_MODE_FULL)
@Component({
selector: 'ds-orgunit',
styleUrls: ['./orgunit.component.scss'],
templateUrl: './orgunit.component.html'
})
/**
* The component for displaying metadata and relations of an item of the type Organisation Unit
*/
export class OrgunitComponent extends ItemComponent implements OnInit {
/**
* The people related to this organisation unit
*/
people$: Observable<Item[]>;
/**
* The projects related to this organisation unit
*/
projects$: Observable<Item[]>;
/**
* The publications related to this organisation unit
*/
publications$: Observable<Item[]>;
constructor(
@Inject(ITEM) public item: Item,
private ids: ItemDataService
) {
super(item);
}
ngOnInit(): void {
super.ngOnInit();
if (isNotEmpty(this.resolvedRelsAndTypes$)) {
this.people$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isPersonOfOrgUnit'),
relationsToItems(this.item.id, this.ids)
);
this.projects$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isProjectOfOrgUnit'),
relationsToItems(this.item.id, this.ids)
);
this.publications$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isPublicationOfOrgUnit'),
relationsToItems(this.item.id, this.ids)
);
}
}}

View File

@@ -0,0 +1,58 @@
<h2 class="item-page-title-field">
<ds-metadata-values [values]="item?.filterMetadata(['dc.contributor.author'])"></ds-metadata-values>
</h2>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="this.item.getThumbnail() | async" [defaultImage]="'assets/images/person-placeholder.png'"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="item"
[fields]="['person.identifier.email']"
[label]="'person.page.email'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['person.identifier.orcid']"
[label]="'person.page.orcid'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['person.identifier.birthdate']"
[label]="'person.page.birthdate'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['person.identifier.staffid']"
[label]="'person.page.staffid'">
</ds-generic-item-page-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-related-items
[items]="projects$ | async"
[label]="'relationships.isProjectOf' | translate">
</ds-related-items>
<ds-related-items
[items]="orgUnits$ | async"
[label]="'relationships.isOrgUnitOf' | translate">
</ds-related-items>
<ds-generic-item-page-field [item]="item"
[fields]="['person.identifier.jobtitle']"
[label]="'person.page.jobtitle'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['person.identifier.lastname']"
[label]="'person.page.lastname'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['person.identifier.firstname']"
[label]="'person.page.firstname'">
</ds-generic-item-page-field>
<div>
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']">
{{"item.page.link.full" | translate}}
</a>
</div>
</div>
<div class="mt-5 w-100">
<ds-related-entities-search [item]="item"
[relationType]="'isAuthorOfPublication'">
</ds-related-entities-search>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../../../styles/variables.scss';

View File

@@ -0,0 +1,50 @@
import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
import { PersonComponent } from './person.component';
import { of as observableOf } from 'rxjs';
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [
{
key: 'person.identifier.email',
language: 'en_US',
value: 'fake@email.com'
},
{
key: 'person.identifier.orcid',
language: 'en_US',
value: 'ORCID-1'
},
{
key: 'person.identifier.birthdate',
language: 'en_US',
value: '1993'
},
{
key: 'person.identifier.staffid',
language: 'en_US',
value: '1'
},
{
key: 'person.identifier.jobtitle',
language: 'en_US',
value: 'Developer'
},
{
key: 'person.identifier.lastname',
language: 'en_US',
value: 'Doe'
},
{
key: 'person.identifier.firstname',
language: 'en_US',
value: 'John'
}],
relationships: createRelationshipsObservable()
});
describe('PersonComponent', getItemPageFieldsTest(mockItem, PersonComponent));

View File

@@ -0,0 +1,77 @@
import { Component, Inject } from '@angular/core';
import { Observable , of as observableOf } from 'rxjs';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model';
import { rendersItemType } from '../../../../shared/items/item-type-decorator';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
import { isNotEmpty } from '../../../../shared/empty.util';
import { ItemComponent, filterRelationsByTypeLabel, relationsToItems } from '../shared/item.component';
import { VIEW_MODE_FULL } from '../../item-page.component';
@rendersItemType('Person', VIEW_MODE_FULL)
@Component({
selector: 'ds-person',
styleUrls: ['./person.component.scss'],
templateUrl: './person.component.html'
})
/**
* The component for displaying metadata and relations of an item of the type Person
*/
export class PersonComponent extends ItemComponent {
/**
* The publications related to this person
*/
publications$: Observable<Item[]>;
/**
* The projects related to this person
*/
projects$: Observable<Item[]>;
/**
* The organisation units related to this person
*/
orgUnits$: Observable<Item[]>;
/**
* The applied fixed filter
*/
fixedFilter$: Observable<string>;
/**
* The query used for applying the fixed filter
*/
fixedFilterQuery: string;
constructor(
@Inject(ITEM) public item: Item,
private ids: ItemDataService,
private fixedFilterService: SearchFixedFilterService
) {
super(item);
}
ngOnInit(): void {
super.ngOnInit();
if (isNotEmpty(this.resolvedRelsAndTypes$)) {
this.publications$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isPublicationOfAuthor'),
relationsToItems(this.item.id, this.ids)
);
this.projects$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isProjectOfPerson'),
relationsToItems(this.item.id, this.ids)
);
this.orgUnits$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isOrgUnitOfPerson'),
relationsToItems(this.item.id, this.ids)
);
this.fixedFilterQuery = this.fixedFilterService.getQueryByRelations('isAuthorOfPublication', this.item.id);
this.fixedFilter$ = observableOf('publication');
}
}
}

View File

@@ -0,0 +1,49 @@
<h2 class="item-page-title-field">
<ds-metadata-values [values]="item?.filterMetadata(['project.identifier.name'])"></ds-metadata-values>
</h2>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="this.item.getThumbnail() | async" [defaultImage]="'assets/images/project-placeholder.png'"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="item"
[fields]="['project.identifier.status']"
[label]="'project.page.status'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['project.identifier.id']"
[label]="'project.page.id'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['project.identifier.expectedcompletion']"
[label]="'project.page.expectedcompletion'">
</ds-generic-item-page-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-related-items
[items]="people$ | async"
[label]="'relationships.isPersonOf' | translate">
</ds-related-items>
<ds-related-items
[items]="publications$ | async"
[label]="'relationships.isPublicationOf' | translate">
</ds-related-items>
<ds-related-items
[items]="orgUnits$ | async"
[label]="'relationships.isOrgUnitOf' | translate">
</ds-related-items>
<ds-generic-item-page-field [item]="item"
[fields]="['project.identifier.description']"
[label]="'project.page.description'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['project.identifier.keyword']"
[label]="'project.page.keyword'">
</ds-generic-item-page-field>
<div>
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']">
{{"item.page.link.full" | translate}}
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../../../styles/variables.scss';

View File

@@ -0,0 +1,40 @@
import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { createRelationshipsObservable, getItemPageFieldsTest } from '../shared/item.component.spec';
import { ProjectComponent } from './project.component';
import { of as observableOf } from 'rxjs';
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [
{
key: 'project.identifier.status',
language: 'en_US',
value: 'published'
},
{
key: 'project.identifier.id',
language: 'en_US',
value: '1'
},
{
key: 'project.identifier.expectedcompletion',
language: 'en_US',
value: 'exp comp'
},
{
key: 'project.identifier.description',
language: 'en_US',
value: 'keyword'
},
{
key: 'project.identifier.keyword',
language: 'en_US',
value: 'keyword'
}],
relationships: createRelationshipsObservable()
});
describe('ProjectComponent', getItemPageFieldsTest(mockItem, ProjectComponent));

View File

@@ -0,0 +1,63 @@
import { Component, Inject, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model';
import { rendersItemType } from '../../../../shared/items/item-type-decorator';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { isNotEmpty } from '../../../../shared/empty.util';
import { ItemComponent, filterRelationsByTypeLabel, relationsToItems } from '../shared/item.component';
import { VIEW_MODE_FULL } from '../../item-page.component';
@rendersItemType('Project', VIEW_MODE_FULL)
@Component({
selector: 'ds-project',
styleUrls: ['./project.component.scss'],
templateUrl: './project.component.html'
})
/**
* The component for displaying metadata and relations of an item of the type Project
*/
export class ProjectComponent extends ItemComponent implements OnInit {
/**
* The people related to this project
*/
people$: Observable<Item[]>;
/**
* The publications related to this project
*/
publications$: Observable<Item[]>;
/**
* The organisation units related to this project
*/
orgUnits$: Observable<Item[]>;
constructor(
@Inject(ITEM) public item: Item,
private ids: ItemDataService
) {
super(item);
}
ngOnInit(): void {
super.ngOnInit();
if (isNotEmpty(this.resolvedRelsAndTypes$)) {
this.people$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isPersonOfProject'),
relationsToItems(this.item.id, this.ids)
);
this.publications$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isPublicationOfProject'),
relationsToItems(this.item.id, this.ids)
);
this.orgUnits$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isOrgUnitOfProject'),
relationsToItems(this.item.id, this.ids)
);
}
}
}

View File

@@ -0,0 +1,58 @@
<ds-item-page-title-field [item]="item"></ds-item-page-title-field>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="this.item.getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-item-page-file-section [item]="item"></ds-item-page-file-section>
<ds-item-page-date-field [item]="item"></ds-item-page-date-field>
<ds-item-page-author-field *ngIf="!(authors$ | async)" [item]="item"></ds-item-page-author-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journal.title']"
[label]="'publication.page.journal-title'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journal.identifier.issn']"
[label]="'publication.page.journal-issn'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['journalvolume.identifier.name']"
[label]="'publication.page.volume-title'">
</ds-generic-item-page-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-metadata-representation-list
[label]="'relationships.isAuthorOf' | translate"
[representations]="authors$ | async">
</ds-metadata-representation-list>
<ds-related-items
[items]="projects$ | async"
[label]="'relationships.isProjectOf' | translate">
</ds-related-items>
<ds-related-items
[items]="orgUnits$ | async"
[label]="'relationships.isOrgUnitOf' | translate">
</ds-related-items>
<ds-related-items
[items]="journalIssues$ | async"
[label]="'relationships.isJournalIssueOf' | translate">
</ds-related-items>
<ds-item-page-abstract-field [item]="item"></ds-item-page-abstract-field>
<ds-generic-item-page-field [item]="item"
[fields]="['dc.subject']"
[separator]="','"
[label]="'item.page.subject'">
</ds-generic-item-page-field>
<ds-generic-item-page-field [item]="item"
[fields]="['dc.identifier.citation']"
[label]="'item.page.citation'">
</ds-generic-item-page-field>
<ds-item-page-uri-field [item]="item"></ds-item-page-uri-field>
<ds-item-page-collections [item]="item"></ds-item-page-collections>
<div>
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id + '/full']">
{{"item.page.link.full" | translate}}
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../../../styles/variables.scss';

View File

@@ -0,0 +1,89 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { By } from '@angular/platform-browser';
import { createRelationshipsObservable } from '../shared/item.component.spec';
import { PublicationComponent } from './publication.component';
import { of as observableOf } from 'rxjs';
const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [],
relationships: createRelationshipsObservable()
});
describe('PublicationComponent', () => {
let comp: PublicationComponent;
let fixture: ComponentFixture<PublicationComponent>;
const searchFixedFilterServiceStub = {
/* tslint:disable:no-empty */
getQueryByRelations: () => {}
/* tslint:enable:no-empty */
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [PublicationComponent, GenericItemPageFieldComponent, TruncatePipe],
providers: [
{provide: ITEM, useValue: mockItem},
{provide: ItemDataService, useValue: {}},
{provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub},
{provide: TruncatableService, useValue: {}}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(PublicationComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(PublicationComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
}));
it('should contain a component to display the date', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain a component to display the author', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain a component to display the abstract', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain a component to display the uri', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
it('should contain a component to display the collections', () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections'));
expect(fields.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model';
import {
DEFAULT_ITEM_TYPE,
rendersItemType
} from '../../../../shared/items/item-type-decorator';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { ItemComponent, filterRelationsByTypeLabel, relationsToItems } from '../shared/item.component';
import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model';
import { VIEW_MODE_FULL } from '../../item-page.component';
@rendersItemType('Publication', VIEW_MODE_FULL)
@rendersItemType(DEFAULT_ITEM_TYPE, VIEW_MODE_FULL)
@Component({
selector: 'ds-publication',
styleUrls: ['./publication.component.scss'],
templateUrl: './publication.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PublicationComponent extends ItemComponent implements OnInit {
/**
* The authors related to this publication
*/
authors$: Observable<MetadataRepresentation[]>;
/**
* The projects related to this publication
*/
projects$: Observable<Item[]>;
/**
* The organisation units related to this publication
*/
orgUnits$: Observable<Item[]>;
/**
* The journal issues related to this publication
*/
journalIssues$: Observable<Item[]>;
constructor(
@Inject(ITEM) public item: Item,
private ids: ItemDataService
) {
super(item);
}
ngOnInit(): void {
super.ngOnInit();
if (this.resolvedRelsAndTypes$) {
this.authors$ = this.buildRepresentations('Person', 'dc.contributor.author', this.ids);
this.projects$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isProjectOfPublication'),
relationsToItems(this.item.id, this.ids)
);
this.orgUnits$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isOrgUnitOfPublication'),
relationsToItems(this.item.id, this.ids)
);
this.journalIssues$ = this.resolvedRelsAndTypes$.pipe(
filterRelationsByTypeLabel('isJournalIssueOfPublication'),
relationsToItems(this.item.id, this.ids)
);
}
}
}

View File

@@ -0,0 +1,431 @@
import { Item } from '../../../../core/shared/item.model';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loader';
import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { isNotEmpty } from '../../../../shared/empty.util';
import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { compareArraysUsing, compareArraysUsingIds, ItemComponent } from './item.component';
import { of as observableOf } from 'rxjs';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ItemPageComponent } from '../../item-page.component';
import { VarDirective } from '../../../../shared/utils/var.directive';
import { ActivatedRoute } from '@angular/router';
import { MetadataService } from '../../../../core/metadata/metadata.service';
import { of } from 'rxjs/internal/observable/of';
import { Observable } from 'rxjs/internal/Observable';
import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model';
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model';
/**
* Create a generic test for an item-page-fields component using a mockItem and the type of component
* @param {Item} mockItem The item to use for testing. The item needs to contain just the metadata necessary to
* execute the tests for it's component.
* @param component The type of component to create test cases for.
* @returns {() => void} Returns a specDefinition for the test.
*/
export function getItemPageFieldsTest(mockItem: Item, component) {
return () => {
let comp: any;
let fixture: ComponentFixture<any>;
const searchFixedFilterServiceStub = {
/* tslint:disable:no-empty */
getQueryByRelations: () => {}
/* tslint:enable:no-empty */
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
declarations: [component, GenericItemPageFieldComponent, TruncatePipe],
providers: [
{provide: ITEM, useValue: mockItem},
{provide: ItemDataService, useValue: {}},
{provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub},
{provide: TruncatableService, useValue: {}}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(component, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(component);
comp = fixture.componentInstance;
fixture.detectChanges();
}));
for (const metadata of mockItem.metadata) {
it(`should be calling a component with metadata field ${metadata.key}`, () => {
const fields = fixture.debugElement.queryAll(By.css('ds-generic-item-page-field'));
expect(containsFieldInput(fields, metadata.key)).toBeTruthy();
});
}
}
}
/**
* Checks whether in a list of debug elements, at least one of them contains a specific metadata key in their
* fields property.
* @param {DebugElement[]} fields List of debug elements to check
* @param {string} metadataKey A metadata key to look for
* @returns {boolean}
*/
export function containsFieldInput(fields: DebugElement[], metadataKey: string): boolean {
for (const field of fields) {
const fieldComp = field.componentInstance;
if (isNotEmpty(fieldComp.fields)) {
if (fieldComp.fields.indexOf(metadataKey) > -1) {
return true;
}
}
}
return false;
}
export function createRelationshipsObservable() {
return observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [
Object.assign(new Relationship(), {
relationshipType: observableOf(new RemoteData(false, false, true, null, new RelationshipType()))
})
])));
}
describe('ItemComponent', () => {
const arr1 = [
{
id: 1,
name: 'test'
},
{
id: 2,
name: 'another test'
},
{
id: 3,
name: 'one last test'
}
];
const arrWithWrongId = [
{
id: 1,
name: 'test'
},
{
id: 5, // Wrong id on purpose
name: 'another test'
},
{
id: 3,
name: 'one last test'
}
];
const arrWithWrongName = [
{
id: 1,
name: 'test'
},
{
id: 2,
name: 'wrong test' // Wrong name on purpose
},
{
id: 3,
name: 'one last test'
}
];
const arrWithDifferentOrder = [arr1[0], arr1[2], arr1[1]];
const arrWithOneMore = [...arr1, {
id: 4,
name: 'fourth test'
}];
const arrWithAddedProperties = [
{
id: 1,
name: 'test',
extra: 'extra property'
},
{
id: 2,
name: 'another test',
extra: 'extra property'
},
{
id: 3,
name: 'one last test',
extra: 'extra property'
}
];
const arrOfPrimitiveTypes = [1, 2, 3, 4];
const arrOfPrimitiveTypesWithOneWrong = [1, 5, 3, 4];
const arrOfPrimitiveTypesWithDifferentOrder = [1, 3, 2, 4];
const arrOfPrimitiveTypesWithOneMore = [1, 2, 3, 4, 5];
describe('when calling compareArraysUsing', () => {
describe('and comparing by id', () => {
const compare = compareArraysUsing<any>((o) => o.id);
it('should return true when comparing the same array', () => {
expect(compare(arr1, arr1)).toBeTruthy();
});
it('should return true regardless of the order', () => {
expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy();
});
it('should return true regardless of other properties being different', () => {
expect(compare(arr1, arrWithWrongName)).toBeTruthy();
});
it('should return true regardless of extra properties', () => {
expect(compare(arr1, arrWithAddedProperties)).toBeTruthy();
});
it('should return false when the ids don\'t match', () => {
expect(compare(arr1, arrWithWrongId)).toBeFalsy();
});
it('should return false when the sizes don\'t match', () => {
expect(compare(arr1, arrWithOneMore)).toBeFalsy();
});
});
describe('and comparing by name', () => {
const compare = compareArraysUsing<any>((o) => o.name);
it('should return true when comparing the same array', () => {
expect(compare(arr1, arr1)).toBeTruthy();
});
it('should return true regardless of the order', () => {
expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy();
});
it('should return true regardless of other properties being different', () => {
expect(compare(arr1, arrWithWrongId)).toBeTruthy();
});
it('should return true regardless of extra properties', () => {
expect(compare(arr1, arrWithAddedProperties)).toBeTruthy();
});
it('should return false when the names don\'t match', () => {
expect(compare(arr1, arrWithWrongName)).toBeFalsy();
});
it('should return false when the sizes don\'t match', () => {
expect(compare(arr1, arrWithOneMore)).toBeFalsy();
});
});
describe('and comparing by full objects', () => {
const compare = compareArraysUsing<any>((o) => o);
it('should return true when comparing the same array', () => {
expect(compare(arr1, arr1)).toBeTruthy();
});
it('should return true regardless of the order', () => {
expect(compare(arr1, arrWithDifferentOrder)).toBeTruthy();
});
it('should return false when extra properties are added', () => {
expect(compare(arr1, arrWithAddedProperties)).toBeFalsy();
});
it('should return false when the ids don\'t match', () => {
expect(compare(arr1, arrWithWrongId)).toBeFalsy();
});
it('should return false when the names don\'t match', () => {
expect(compare(arr1, arrWithWrongName)).toBeFalsy();
});
it('should return false when the sizes don\'t match', () => {
expect(compare(arr1, arrWithOneMore)).toBeFalsy();
});
});
describe('and comparing with primitive objects as source', () => {
const compare = compareArraysUsing<any>((o) => o);
it('should return true when comparing the same array', () => {
expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypes)).toBeTruthy();
});
it('should return true regardless of the order', () => {
expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithDifferentOrder)).toBeTruthy();
});
it('should return false when at least one is wrong', () => {
expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithOneWrong)).toBeFalsy();
});
it('should return false when the sizes don\'t match', () => {
expect(compare(arrOfPrimitiveTypes, arrOfPrimitiveTypesWithOneMore)).toBeFalsy();
});
});
});
describe('when calling compareArraysUsingIds', () => {
const compare = compareArraysUsingIds();
it('should return true when comparing the same array', () => {
expect(compare(arr1 as any, arr1 as any)).toBeTruthy();
});
it('should return true regardless of the order', () => {
expect(compare(arr1 as any, arrWithDifferentOrder as any)).toBeTruthy();
});
it('should return true regardless of other properties being different', () => {
expect(compare(arr1 as any, arrWithWrongName as any)).toBeTruthy();
});
it('should return true regardless of extra properties', () => {
expect(compare(arr1 as any, arrWithAddedProperties as any)).toBeTruthy();
});
it('should return false when the ids don\'t match', () => {
expect(compare(arr1 as any, arrWithWrongId as any)).toBeFalsy();
});
it('should return false when the sizes don\'t match', () => {
expect(compare(arr1 as any, arrWithOneMore as any)).toBeFalsy();
});
});
describe('when calling buildRepresentations', () => {
let comp: ItemComponent;
let fixture: ComponentFixture<ItemComponent>;
const metadataField = 'dc.contributor.author';
const mockItem = Object.assign(new Item(), {
id: '1',
uuid: '1',
metadata: [
{
key: metadataField,
value: 'Second value',
place: 1
},
{
key: metadataField,
value: 'Third value',
place: 2,
authority: 'virtual::123'
},
{
key: metadataField,
value: 'First value',
place: 0
},
{
key: metadataField,
value: 'Fourth value',
place: 3,
authority: '123'
}
],
relationships: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [
Object.assign(new Relationship(), {
uuid: '123',
id: '123',
leftId: '1',
rightId: '2',
relationshipType: observableOf(new RemoteData(false, false, true, null, new RelationshipType()))
})
])))
});
const relatedItem = Object.assign(new Item(), {
id: '2',
metadata: [
{
key: 'dc.title',
value: 'related item'
}
]
});
const mockItemDataService = {
findById: (id) => {
if (id === relatedItem.id) {
return observableOf(new RemoteData(false, false, true, null, relatedItem))
}
}
} as ItemDataService;
let representations: Observable<MetadataRepresentation[]>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
}), BrowserAnimationsModule],
declarations: [ItemComponent, VarDirective],
providers: [
{provide: ITEM, useValue: mockItem}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(ItemComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
representations = comp.buildRepresentations('bogus', metadataField, mockItemDataService);
}));
it('should contain exactly 4 metadata-representations', () => {
representations.subscribe((reps: MetadataRepresentation[]) => {
expect(reps.length).toEqual(4);
});
});
it('should have all the representations in the correct order', () => {
representations.subscribe((reps: MetadataRepresentation[]) => {
expect(reps[0].getValue()).toEqual('First value');
expect(reps[1].getValue()).toEqual('Second value');
expect(reps[2].getValue()).toEqual('related item');
expect(reps[3].getValue()).toEqual('Fourth value');
});
});
it('should have created the correct MetadatumRepresentation and ItemMetadataRepresentation objects for the correct Metadata', () => {
representations.subscribe((reps: MetadataRepresentation[]) => {
expect(reps[0] instanceof MetadatumRepresentation).toEqual(true);
expect(reps[1] instanceof MetadatumRepresentation).toEqual(true);
expect(reps[2] instanceof ItemMetadataRepresentation).toEqual(true);
expect(reps[3] instanceof MetadatumRepresentation).toEqual(true);
});
});
})
});

View File

@@ -0,0 +1,190 @@
import { Component, Inject, OnInit } from '@angular/core';
import { Observable , zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs';
import { distinctUntilChanged, filter, flatMap, map } from 'rxjs/operators';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../../core/shared/item.model';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
import { hasNoValue, hasValue } from '../../../../shared/empty.util';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model';
import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { of } from 'rxjs/internal/observable/of';
import { MetadataValue } from '../../../../core/shared/metadata.models';
/**
* Operator for comparing arrays using a mapping function
* The mapping function should turn the source array into an array of basic types, so that the array can
* be compared using these basic types.
* For example: "(o) => o.id" will compare the two arrays by comparing their content by id.
* @param mapFn Function for mapping the arrays
*/
export const compareArraysUsing = <T>(mapFn: (t: T) => any) =>
(a: T[], b: T[]): boolean => {
if (!Array.isArray(a) || ! Array.isArray(b)) {
return false
}
const aIds = a.map(mapFn);
const bIds = b.map(mapFn);
return aIds.length === bIds.length &&
aIds.every((e) => bIds.includes(e)) &&
bIds.every((e) => aIds.includes(e));
};
/**
* Operator for comparing arrays using the object's ids
*/
export const compareArraysUsingIds = <T extends { id: string }>() =>
compareArraysUsing((t: T) => hasValue(t) ? t.id : undefined);
/**
* Fetch the relationships which match the type label given
* @param {string} label Type label
* @returns {(source: Observable<[Relationship[] , RelationshipType[]]>) => Observable<Relationship[]>}
*/
export const filterRelationsByTypeLabel = (label: string) =>
(source: Observable<[Relationship[], RelationshipType[]]>): Observable<Relationship[]> =>
source.pipe(
map(([relsCurrentPage, relTypesCurrentPage]) =>
relsCurrentPage.filter((rel: Relationship, idx: number) =>
hasValue(relTypesCurrentPage[idx]) && (relTypesCurrentPage[idx].leftLabel === label ||
relTypesCurrentPage[idx].rightLabel === label)
)
),
distinctUntilChanged(compareArraysUsingIds())
);
/**
* Operator for turning a list of relationships into a list of the relevant items
* @param {string} thisId The item's id of which the relations belong to
* @param {ItemDataService} ids The ItemDataService to fetch items from the REST API
* @returns {(source: Observable<Relationship[]>) => Observable<Item[]>}
*/
export const relationsToItems = (thisId: string, ids: ItemDataService) =>
(source: Observable<Relationship[]>): Observable<Item[]> =>
source.pipe(
flatMap((rels: Relationship[]) =>
observableZip(
...rels.map((rel: Relationship) => {
let queryId = rel.leftId;
if (rel.leftId === thisId) {
queryId = rel.rightId;
}
return ids.findById(queryId);
})
)
),
map((arr: Array<RemoteData<Item>>) =>
arr
.filter((d: RemoteData<Item>) => d.hasSucceeded)
.map((d: RemoteData<Item>) => d.payload)),
distinctUntilChanged(compareArraysUsingIds()),
);
/**
* Operator for turning a list of relationships into a list of metadatarepresentations given the original metadata
* @param thisId The id of the parent item
* @param itemType The type of relation this list resembles (for creating representations)
* @param metadata The list of original Metadatum objects
* @param ids The ItemDataService to use for fetching Items from the Rest API
*/
export const relationsToRepresentations = (thisId: string, itemType: string, metadata: MetadataValue[], ids: ItemDataService) =>
(source: Observable<Relationship[]>): Observable<MetadataRepresentation[]> =>
source.pipe(
flatMap((rels: Relationship[]) =>
observableZip(
...metadata
.map((metadatum: any) => Object.assign(new MetadataValue(), metadatum))
.map((metadatum: MetadataValue) => {
if (metadatum.isVirtual) {
const matchingRels = rels.filter((rel: Relationship) => ('' + rel.id) === metadatum.virtualValue);
if (matchingRels.length > 0) {
const matchingRel = matchingRels[0];
let queryId = matchingRel.leftId;
if (matchingRel.leftId === thisId) {
queryId = matchingRel.rightId;
}
return ids.findById(queryId).pipe(
getSucceededRemoteData(),
map((d: RemoteData<Item>) => Object.assign(new ItemMetadataRepresentation(itemType), d.payload))
);
}
} else {
return of(Object.assign(new MetadatumRepresentation(itemType), metadatum));
}
})
)
)
);
@Component({
selector: 'ds-item',
template: ''
})
/**
* A generic component for displaying metadata and relations of an item
*/
export class ItemComponent implements OnInit {
/**
* Resolved relationships and types together in one observable
*/
resolvedRelsAndTypes$: Observable<[Relationship[], RelationshipType[]]>;
constructor(
@Inject(ITEM) public item: Item
) {}
ngOnInit(): void {
const relationships$ = this.item.relationships;
if (relationships$) {
const relsCurrentPage$ = relationships$.pipe(
filter((rd: RemoteData<PaginatedList<Relationship>>) => rd.hasSucceeded),
getRemoteDataPayload(),
map((pl: PaginatedList<Relationship>) => pl.page),
distinctUntilChanged(compareArraysUsingIds())
);
const relTypesCurrentPage$ = relsCurrentPage$.pipe(
flatMap((rels: Relationship[]) =>
observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe(
map(([...arr]: Array<RemoteData<RelationshipType>>) => arr.map((d: RemoteData<RelationshipType>) => d.payload))
)
),
distinctUntilChanged(compareArraysUsingIds())
);
this.resolvedRelsAndTypes$ = observableCombineLatest(
relsCurrentPage$,
relTypesCurrentPage$
);
}
}
/**
* Build a list of MetadataRepresentations for the current item. This combines all metadata and relationships of a
* certain type.
* @param itemType The type of item we're building representations of. Used for matching templates.
* @param metadataField The metadata field that resembles the item type.
* @param itemDataService ItemDataService to turn relations into items.
*/
buildRepresentations(itemType: string, metadataField: string, itemDataService: ItemDataService): Observable<MetadataRepresentation[]> {
const metadata = this.item.findMetadataSortedByPlace(metadataField);
const relsCurrentPage$ = this.item.relationships.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((pl: PaginatedList<Relationship>) => pl.page),
distinctUntilChanged(compareArraysUsingIds())
);
return relsCurrentPage$.pipe(
relationsToRepresentations(this.item.id, itemType, metadata, itemDataService)
);
}
}

View File

@@ -0,0 +1,5 @@
<ds-metadata-field-wrapper *ngIf="representations && representations.length > 0" [label]="label">
<ds-item-type-switcher *ngFor="let rep of representations"
[object]="rep" [viewMode]="viewMode">
</ds-item-type-switcher>
</ds-metadata-field-wrapper>

View File

@@ -0,0 +1,40 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { MetadataRepresentationListComponent } from './metadata-representation-list.component';
import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
const itemType = 'type';
const metadataRepresentation1 = new MetadatumRepresentation(itemType);
const metadataRepresentation2 = new ItemMetadataRepresentation(itemType);
const representations = [metadataRepresentation1, metadataRepresentation2];
describe('MetadataRepresentationListComponent', () => {
let comp: MetadataRepresentationListComponent;
let fixture: ComponentFixture<MetadataRepresentationListComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [],
declarations: [MetadataRepresentationListComponent],
providers: [],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MetadataRepresentationListComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(MetadataRepresentationListComponent);
comp = fixture.componentInstance;
comp.representations = representations;
fixture.detectChanges();
}));
it(`should load ${representations.length} item-type-switcher components`, () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-type-switcher'));
expect(fields.length).toBe(representations.length);
});
});

View File

@@ -0,0 +1,31 @@
import { Component, Input } from '@angular/core';
import * as viewMode from '../../../shared/view-mode';
import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model';
export const VIEW_MODE_METADATA = 'metadata';
@Component({
selector: 'ds-metadata-representation-list',
templateUrl: './metadata-representation-list.component.html'
})
/**
* This component is used for displaying metadata
* It expects a list of MetadataRepresentation objects and a label to put on top of the list
*/
export class MetadataRepresentationListComponent {
/**
* A list of metadata-representations to display
*/
@Input() representations: MetadataRepresentation[];
/**
* An i18n label to use as a title for the list
*/
@Input() label: string;
/**
* The view-mode we're currently on
* @type {ElementViewMode}
*/
viewMode = VIEW_MODE_METADATA;
}

View File

@@ -0,0 +1,6 @@
<ds-filtered-search-page
[fixedFilterQuery]="fixedFilter"
[fixedFilter$]="fixedFilter$"
[searchEnabled]="searchEnabled"
[sideBarWidth]="sideBarWidth">
</ds-filtered-search-page>

View File

@@ -0,0 +1,56 @@
import { RelatedEntitiesSearchComponent } from './related-entities-search.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
import { Item } from '../../../../core/shared/item.model';
describe('RelatedEntitiesSearchComponent', () => {
let comp: RelatedEntitiesSearchComponent;
let fixture: ComponentFixture<RelatedEntitiesSearchComponent>;
let fixedFilterService: SearchFixedFilterService;
const mockItem = Object.assign(new Item(), {
id: 'id1'
});
const mockRelationType = 'publicationsOfAuthor';
const mockRelationEntityType = 'publication';
const mockFilter= `f.${mockRelationType}=${mockItem.id}`;
const fixedFilterServiceStub = {
getFilterByRelation: () => mockFilter
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
declarations: [RelatedEntitiesSearchComponent],
providers: [
{ provide: SearchFixedFilterService, useValue: fixedFilterServiceStub }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(RelatedEntitiesSearchComponent);
comp = fixture.componentInstance;
fixedFilterService = (comp as any).fixedFilterService;
comp.relationType = mockRelationType;
comp.item = mockItem;
comp.relationEntityType = mockRelationEntityType;
fixture.detectChanges();
});
it('should create a fixedFilter', () => {
expect(comp.fixedFilter).toEqual(mockFilter);
});
it('should create a fixedFilter$', () => {
comp.fixedFilter$.subscribe((fixedFilter) => {
expect(fixedFilter).toEqual(mockRelationEntityType);
})
});
});

View File

@@ -0,0 +1,64 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Item } from '../../../../core/shared/item.model';
import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service';
import { isNotEmpty } from '../../../../shared/empty.util';
import { of } from 'rxjs/internal/observable/of';
@Component({
selector: 'ds-related-entities-search',
templateUrl: './related-entities-search.component.html'
})
/**
* A component to show related items as search results.
* Related items can be facetted, or queried using an
* optional search box.
*/
export class RelatedEntitiesSearchComponent implements OnInit {
/**
* The type of relationship to fetch items for
* e.g. 'isAuthorOfPublication'
*/
@Input() relationType: string;
/**
* The item to render relationships for
*/
@Input() item: Item;
/**
* The entity type of the relationship items to be displayed
* e.g. 'publication'
* This determines the title of the search results (if search is enabled)
*/
@Input() relationEntityType: string;
/**
* Whether or not the search bar and title should be displayed (defaults to true)
* @type {boolean}
*/
@Input() searchEnabled = true;
/**
* The ratio of the sidebar's width compared to the search results (1-12) (defaults to 4)
* @type {number}
*/
@Input() sideBarWidth = 4;
fixedFilter: string;
fixedFilter$: Observable<string>;
constructor(private fixedFilterService: SearchFixedFilterService) {
}
ngOnInit(): void {
if (isNotEmpty(this.relationType) && isNotEmpty(this.item)) {
this.fixedFilter = this.fixedFilterService.getFilterByRelation(this.relationType, this.item.id);
}
if (isNotEmpty(this.relationEntityType)) {
this.fixedFilter$ = of(this.relationEntityType);
}
}
}

View File

@@ -0,0 +1,31 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
export const VIEW_MODE_ELEMENT = 'element';
@Component({
selector: 'ds-related-items',
styleUrls: ['./related-items.component.scss'],
templateUrl: './related-items.component.html'
})
/**
* This component is used for displaying relations between items
* It expects a list of items to display and a label to put on top
*/
export class RelatedItemsComponent {
/**
* A list of items to display
*/
@Input() items: Item[];
/**
* An i18n label to use as a title for the list (usually describes the relation)
*/
@Input() label: string;
/**
* The view-mode we're currently on
* @type {ElementViewMode}
*/
viewMode = VIEW_MODE_ELEMENT;
}

View File

@@ -0,0 +1,5 @@
<ds-metadata-field-wrapper *ngIf="items && items.length > 0" [label]="label">
<ds-item-type-switcher *ngFor="let item of items"
[object]="item" [viewMode]="viewMode">
</ds-item-type-switcher>
</ds-metadata-field-wrapper>

View File

@@ -0,0 +1,51 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { RelatedItemsComponent } from './related-items-component';
import { Item } from '../../../core/shared/item.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PageInfo } from '../../../core/shared/page-info.model';
import { By } from '@angular/platform-browser';
import { createRelationshipsObservable } from '../item-types/shared/item.component.spec';
import { of as observableOf } from 'rxjs';
const mockItem1: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [],
relationships: createRelationshipsObservable()
});
const mockItem2: Item = Object.assign(new Item(), {
bitstreams: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []))),
metadata: [],
relationships: createRelationshipsObservable()
});
const mockItems = [mockItem1, mockItem2];
describe('RelatedItemsComponent', () => {
let comp: RelatedItemsComponent;
let fixture: ComponentFixture<RelatedItemsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [],
declarations: [RelatedItemsComponent],
providers: [],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(RelatedItemsComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(RelatedItemsComponent);
comp = fixture.componentInstance;
comp.items = mockItems;
fixture.detectChanges();
}));
it(`should load ${mockItems.length} item-type-switcher components`, () => {
const fields = fixture.debugElement.queryAll(By.css('ds-item-type-switcher'));
expect(fields.length).toBe(mockItems.length);
});
});

View File

@@ -0,0 +1,37 @@
import { FilteredSearchPageComponent } from './filtered-search-page.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { configureSearchComponentTestingModule } from './search-page.component.spec';
import { SearchConfigurationService } from './search-service/search-configuration.service';
describe('FilteredSearchPageComponent', () => {
let comp: FilteredSearchPageComponent;
let fixture: ComponentFixture<FilteredSearchPageComponent>;
let searchConfigService: SearchConfigurationService;
beforeEach(async(() => {
configureSearchComponentTestingModule(FilteredSearchPageComponent);
}));
beforeEach(() => {
fixture = TestBed.createComponent(FilteredSearchPageComponent);
comp = fixture.componentInstance;
searchConfigService = (comp as any).searchConfigService;
fixture.detectChanges();
});
describe('when fixedFilterQuery is defined', () => {
const fixedFilterQuery = 'fixedFilterQuery';
beforeEach(() => {
spyOn(searchConfigService, 'updateFixedFilter').and.callThrough();
comp.fixedFilterQuery = fixedFilterQuery;
comp.ngOnInit();
fixture.detectChanges();
});
it('should update the paginated search options', () => {
expect(searchConfigService.updateFixedFilter).toHaveBeenCalledWith(fixedFilterQuery);
});
});
});

View File

@@ -0,0 +1,53 @@
import { HostWindowService } from '../shared/host-window.service';
import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
import { SearchService } from './search-service/search.service';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { SearchPageComponent } from './search-page.component';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { pushInOut } from '../shared/animations/push';
import { RouteService } from '../shared/services/route.service';
import { SearchConfigurationService } from './search-service/search-configuration.service';
import { Observable } from 'rxjs';
import { PaginatedSearchOptions } from './paginated-search-options.model';
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
@Component({selector: 'ds-filtered-search-page',
styleUrls: ['./search-page.component.scss'],
templateUrl: './search-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [pushInOut]
})
export class FilteredSearchPageComponent extends SearchPageComponent {
/**
* The actual query for the fixed filter.
* If empty, the query will be determined by the route parameter called 'filter'
*/
@Input() fixedFilterQuery: string;
constructor(protected service: SearchService,
protected sidebarService: SearchSidebarService,
protected windowService: HostWindowService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected routeService: RouteService) {
super(service, sidebarService, windowService, filterService, searchConfigService, routeService);
}
/**
* Get the current paginated search options after updating the fixed filter using the fixedFilterQuery input
* This is to make sure the fixed filter is included in the paginated search options, as it is not part of any
* query or route parameters
* @returns {Observable<PaginatedSearchOptions>}
*/
protected getSearchOptions(): Observable<PaginatedSearchOptions> {
this.searchConfigService.updateFixedFilter(this.fixedFilterQuery);
return this.searchConfigService.paginatedSearchOptions;
}
}

View File

@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
@Injectable()
export class FilteredSearchPageGuard implements CanActivate {
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
const filter = route.params.filter;
const newTitle = route.data.title + filter + '.title';
route.data = { title: newTitle };
return true;
}
}

View File

@@ -12,7 +12,7 @@ export class PaginatedSearchOptions extends SearchOptions {
pagination?: PaginationComponentOptions; pagination?: PaginationComponentOptions;
sort?: SortOptions; sort?: SortOptions;
constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], pagination?: PaginationComponentOptions, sort?: SortOptions}) { constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any, pagination?: PaginationComponentOptions, sort?: SortOptions}) {
super(options); super(options);
this.pagination = options.pagination; this.pagination = options.pagination;
this.sort = options.sort; this.sort = options.sort;

View File

@@ -13,8 +13,10 @@ import {
import { SearchFiltersState } from './search-filter.reducer'; import { SearchFiltersState } from './search-filter.reducer';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { FilterType } from '../../search-service/filter-type.model'; import { FilterType } from '../../search-service/filter-type.model';
import { SearchFixedFilterService } from './search-fixed-filter.service';
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub'; import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
describe('SearchFilterService', () => { describe('SearchFilterService', () => {
let service: SearchFilterService; let service: SearchFilterService;
@@ -26,6 +28,12 @@ describe('SearchFilterService', () => {
isOpenByDefault: false, isOpenByDefault: false,
pageSize: 2 pageSize: 2
}); });
const mockFixedFilterService: SearchFixedFilterService = {
getQueryByFilterName: (filter: string) => {
return observableOf(undefined)
}
} as SearchFixedFilterService
const value1 = 'random value'; const value1 = 'random value';
// const value2 = 'another value'; // const value2 = 'another value';
const store: Store<SearchFiltersState> = jasmine.createSpyObj('store', { const store: Store<SearchFiltersState> = jasmine.createSpyObj('store', {
@@ -45,11 +53,15 @@ describe('SearchFilterService', () => {
}, },
addQueryParameterValue: (param: string, value: string) => { addQueryParameterValue: (param: string, value: string) => {
}, },
getQueryParameterValue: (param: string) => {
},
getQueryParameterValues: (param: string) => { getQueryParameterValues: (param: string) => {
return observableOf({}); return observableOf({});
}, },
getQueryParamsWithPrefix: (param: string) => { getQueryParamsWithPrefix: (param: string) => {
return observableOf({}); return observableOf({});
},
getRouteParameterValue: (param: string) => {
} }
/* tslint:enable:no-empty */ /* tslint:enable:no-empty */
}; };
@@ -59,7 +71,7 @@ describe('SearchFilterService', () => {
}; };
beforeEach(() => { beforeEach(() => {
service = new SearchFilterService(store, routeServiceStub); service = new SearchFilterService(store, routeServiceStub, mockFixedFilterService);
}); });
describe('when the initialCollapse method is triggered', () => { describe('when the initialCollapse method is triggered', () => {
@@ -179,4 +191,185 @@ describe('SearchFilterService', () => {
}); });
}); });
describe('when the getCurrentScope method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'getQueryParameterValue');
service.getCurrentScope();
});
it('should call getQueryParameterValue on the route service with scope', () => {
expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('scope');
});
});
describe('when the getCurrentQuery method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'getQueryParameterValue');
service.getCurrentQuery();
});
it('should call getQueryParameterValue on the route service with query', () => {
expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('query');
});
});
describe('when the getCurrentPagination method is called', () => {
let result;
const mockReturn = 5;
beforeEach(() => {
spyOn(routeServiceStub, 'getQueryParameterValue').and.returnValue(observableOf(mockReturn));
result = service.getCurrentPagination();
});
it('should call getQueryParameterValue on the route service with page', () => {
expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('page');
});
it('should call getQueryParameterValue on the route service with pageSize', () => {
expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('pageSize');
});
it('should return an observable containing the correct pagination', () => {
result.subscribe((pagination) => {
expect(pagination.currentPage).toBe(mockReturn);
expect(pagination.pageSize).toBe(mockReturn);
});
});
});
describe('when the getCurrentSort method is called', () => {
let result;
const field = 'author';
const direction = SortDirection.ASC;
beforeEach(() => {
spyOn(routeServiceStub, 'getQueryParameterValue').and.returnValue(observableOf(undefined));
result = service.getCurrentSort(new SortOptions(field, direction));
});
it('should call getQueryParameterValue on the route service with sortDirection', () => {
expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('sortDirection');
});
it('should call getQueryParameterValue on the route service with sortField', () => {
expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('sortField');
});
it('should return an observable containing the correct sortOptions', () => {
result.subscribe((sort) => {
expect(sort.field).toBe(field);
expect(sort.direction).toBe(direction);
});
});
});
describe('when the getCurrentFilters method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'getQueryParamsWithPrefix');
service.getCurrentFilters();
});
it('should call getQueryParamsWithPrefix on the route service with prefix \'f.\'', () => {
expect(routeServiceStub.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
});
});
describe('when the getCurrentFixedFilter method is called', () => {
const filter = 'filter';
beforeEach(() => {
spyOn(routeServiceStub, 'getRouteParameterValue').and.returnValue(observableOf(filter));
spyOn(mockFixedFilterService, 'getQueryByFilterName').and.returnValue(observableOf(filter));
service.getCurrentFixedFilter().subscribe();
});
it('should call getQueryByFilterName on the fixed-filter service with the correct filter', () => {
expect(mockFixedFilterService.getQueryByFilterName).toHaveBeenCalledWith(filter);
});
});
describe('when the getCurrentView method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'getQueryParameterValue');
service.getCurrentView();
});
it('should call getQueryParameterValue on the route service with view', () => {
expect(routeServiceStub.getQueryParameterValue).toHaveBeenCalledWith('view');
});
});
describe('when the getPaginatedSearchOptions method is called', () => {
beforeEach(() => {
spyOn(service, 'getCurrentPagination');
spyOn(service, 'getCurrentSort');
spyOn(service, 'getCurrentView');
spyOn(service, 'getCurrentScope');
spyOn(service, 'getCurrentQuery');
spyOn(service, 'getCurrentFilters');
spyOn(service, 'getCurrentFixedFilter');
service.getPaginatedSearchOptions();
});
it('should call getCurrentPagination to build the paginated search options', () => {
expect(service.getCurrentPagination).toHaveBeenCalled();
});
it('should call getCurrentSort to build the paginated search options', () => {
expect(service.getCurrentSort).toHaveBeenCalled();
});
it('should call getCurrentView to build the paginated search options', () => {
expect(service.getCurrentView).toHaveBeenCalled();
});
it('should call getCurrentScope to build the paginated search options', () => {
expect(service.getCurrentScope).toHaveBeenCalled();
});
it('should call getCurrentQuery to build the paginated search options', () => {
expect(service.getCurrentQuery).toHaveBeenCalled();
});
it('should call getCurrentFilters to build the paginated search options', () => {
expect(service.getCurrentFilters).toHaveBeenCalled();
});
it('should call getCurrentFixedFilter to build the paginated search options', () => {
expect(service.getCurrentFixedFilter).toHaveBeenCalled();
});
});
describe('when the getSearchOptions method is called', () => {
beforeEach(() => {
spyOn(service, 'getCurrentView');
spyOn(service, 'getCurrentScope');
spyOn(service, 'getCurrentQuery');
spyOn(service, 'getCurrentFilters');
spyOn(service, 'getCurrentFixedFilter');
service.getPaginatedSearchOptions();
});
it('should call getCurrentView to build the search options', () => {
expect(service.getCurrentView).toHaveBeenCalled();
});
it('should call getCurrentScope to build the search options', () => {
expect(service.getCurrentScope).toHaveBeenCalled();
});
it('should call getCurrentQuery to build the search options', () => {
expect(service.getCurrentQuery).toHaveBeenCalled();
});
it('should call getCurrentFilters to build the search options', () => {
expect(service.getCurrentFilters).toHaveBeenCalled();
});
it('should call getCurrentFixedFilter to build the search options', () => {
expect(service.getCurrentFixedFilter).toHaveBeenCalled();
});
});
}); });

View File

@@ -1,6 +1,6 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { mergeMap, map, distinctUntilChanged } from 'rxjs/operators';
import { Injectable, InjectionToken } from '@angular/core'; import { Injectable, InjectionToken } from '@angular/core';
import { map } from 'rxjs/operators';
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { import {
@@ -16,6 +16,11 @@ import {
import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; import { hasValue, isNotEmpty, } from '../../../shared/empty.util';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { RouteService } from '../../../shared/services/route.service'; import { RouteService } from '../../../shared/services/route.service';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchOptions } from '../../search-options.model';
import { PaginatedSearchOptions } from '../../paginated-search-options.model';
import { SearchFixedFilterService } from './search-fixed-filter.service';
import { Params } from '@angular/router'; import { Params } from '@angular/router';
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
@@ -29,8 +34,8 @@ export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionTo
export class SearchFilterService { export class SearchFilterService {
constructor(private store: Store<SearchFiltersState>, constructor(private store: Store<SearchFiltersState>,
private routeService: RouteService private routeService: RouteService,
) { private fixedFilterService: SearchFixedFilterService) {
} }
/** /**
@@ -52,6 +57,138 @@ export class SearchFilterService {
return this.routeService.hasQueryParam(paramName); return this.routeService.hasQueryParam(paramName);
} }
/**
* Fetch the current active scope from the query parameters
* @returns {Observable<string>}
*/
getCurrentScope() {
return this.routeService.getQueryParameterValue('scope');
}
/**
* Fetch the current query from the query parameters
* @returns {Observable<string>}
*/
getCurrentQuery() {
return this.routeService.getQueryParameterValue('query');
}
/**
* Fetch the current pagination from query parameters 'page' and 'pageSize'
* and combine them with a given pagination
* @param pagination Pagination options to combine the query parameters with
* @returns {Observable<PaginationComponentOptions>}
*/
getCurrentPagination(pagination: any = {}): Observable<PaginationComponentOptions> {
const page$ = this.routeService.getQueryParameterValue('page');
const size$ = this.routeService.getQueryParameterValue('pageSize');
return observableCombineLatest(page$, size$).pipe(map(([page, size]) => {
return Object.assign(new PaginationComponentOptions(), pagination, {
currentPage: page || 1,
pageSize: size || pagination.pageSize
});
}))
}
/**
* Fetch the current sorting options from query parameters 'sortDirection' and 'sortField'
* and combine them with given sorting options
* @param {SortOptions} defaultSort Sorting options to combine the query parameters with
* @returns {Observable<SortOptions>}
*/
getCurrentSort(defaultSort: SortOptions): Observable<SortOptions> {
const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection');
const sortField$ = this.routeService.getQueryParameterValue('sortField');
return observableCombineLatest(sortDirection$, sortField$).pipe(map(([sortDirection, sortField]) => {
const field = sortField || defaultSort.field;
const direction = SortDirection[sortDirection] || defaultSort.direction;
return new SortOptions(field, direction)
}
))
}
/**
* Fetch the current active filters from the query parameters
* @returns {Observable<Params>}
*/
getCurrentFilters() {
return this.routeService.getQueryParamsWithPrefix('f.');
}
/**
* Fetch the current active fixed filter from the route parameters and return the query by filter name
* @returns {Observable<string>}
*/
getCurrentFixedFilter(): Observable<string> {
const filter: Observable<string> = this.routeService.getRouteParameterValue('filter');
return filter.pipe(mergeMap((f) => this.fixedFilterService.getQueryByFilterName(f)));
}
/**
* Fetch the current view from the query parameters
* @returns {Observable<string>}
*/
getCurrentView() {
return this.routeService.getQueryParameterValue('view');
}
/**
* Fetch the current paginated search options using the getters from above
* and combining them with given defaults
* @param defaults Default paginated search options
* @returns {Observable<PaginatedSearchOptions>}
*/
getPaginatedSearchOptions(defaults: any = {}): Observable<PaginatedSearchOptions> {
return observableCombineLatest(
this.getCurrentPagination(defaults.pagination),
this.getCurrentSort(defaults.sort),
this.getCurrentView(),
this.getCurrentScope(),
this.getCurrentQuery(),
this.getCurrentFilters(),
this.getCurrentFixedFilter()).pipe(
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
map(([pagination, sort, view, scope, query, filters, fixedFilter]) => {
return Object.assign(new PaginatedSearchOptions(defaults),
{
pagination: pagination,
sort: sort,
view: view,
scope: scope || defaults.scope,
query: query,
filters: filters,
fixedFilter: fixedFilter
})
})
)
}
/**
* Fetch the current search options (not paginated) using the getters from above
* and combining them with given defaults
* @param defaults Default search options
* @returns {Observable<SearchOptions>}
*/
getSearchOptions(defaults: any = {}): Observable<SearchOptions> {
return observableCombineLatest(
this.getCurrentView(),
this.getCurrentScope(),
this.getCurrentQuery(),
this.getCurrentFilters(),
this.getCurrentFixedFilter(),
(view, scope, query, filters, fixedFilter) => {
return Object.assign(new SearchOptions(defaults),
{
view: view,
scope: scope || defaults.scope,
query: query,
filters: filters,
fixedFilter: fixedFilter
})
}
)
}
/** /**
* Requests the active filter values set for a given filter * Requests the active filter values set for a given filter
* @param {SearchFilterConfig} filterConfig The configuration for which the filters are active * @param {SearchFilterConfig} filterConfig The configuration for which the filters are active

View File

@@ -0,0 +1,64 @@
import { SearchFixedFilterService } from './search-fixed-filter.service';
import { ResponseCacheEntry } from '../../../core/cache/response-cache.reducer';
import { RouteService } from '../../../shared/services/route.service';
import { RequestService } from '../../../core/data/request.service';
import { ResponseCacheService } from '../../../core/cache/response-cache.service';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response-cache.models';
import { PageInfo } from '../../../core/shared/page-info.model';
import { of as observableOf } from 'rxjs';
describe('SearchFixedFilterService', () => {
let service: SearchFixedFilterService;
const filterQuery = 'filter:query';
const routeServiceStub = {} as RouteService;
const requestServiceStub = Object.assign({
/* tslint:disable:no-empty */
configure: () => {},
/* tslint:enable:no-empty */
generateRequestId: () => 'fake-id'
}) as RequestService;
const responseCacheStub = Object.assign(new ResponseCacheService(undefined), {
get: () => observableOf(Object.assign(new ResponseCacheEntry(), {
response: new FilteredDiscoveryQueryResponse(filterQuery, '200', new PageInfo())
}))
});
const halServiceStub = Object.assign(new HALEndpointService(responseCacheStub, requestServiceStub, undefined), {
getEndpoint: () => observableOf('fake-url')
});
beforeEach(() => {
service = new SearchFixedFilterService(routeServiceStub, requestServiceStub, responseCacheStub, halServiceStub);
});
describe('when getQueryByFilterName is called with a filterName', () => {
it('should return the filter query', () => {
service.getQueryByFilterName('filter').subscribe((query) => {
expect(query).toBe(filterQuery);
});
});
});
describe('when getQueryByFilterName is called without a filterName', () => {
it('should return undefined', () => {
service.getQueryByFilterName(undefined).subscribe((query) => {
expect(query).toBeUndefined();
});
});
});
describe('when getQueryByRelations is called', () => {
const relationType = 'isRelationOf';
const itemUUID = 'c5b277e6-2477-48bb-8993-356710c285f3';
it('should contain the relationType and itemUUID', () => {
const query = service.getQueryByRelations(relationType, itemUUID);
expect(query.length).toBeGreaterThan(relationType.length + itemUUID.length);
expect(query).toContain(relationType);
expect(query).toContain(itemUUID);
});
});
});

View File

@@ -0,0 +1,78 @@
import { Injectable } from '@angular/core';
import { flatMap, map } from 'rxjs/operators';
import { Observable , of as observableOf } from 'rxjs';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { GetRequest, RestRequest } from '../../../core/data/request.models';
import { RequestService } from '../../../core/data/request.service';
import { ResponseParsingService } from '../../../core/data/parsing.service';
import { GenericConstructor } from '../../../core/shared/generic-constructor';
import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service';
import { hasValue } from '../../../shared/empty.util';
import { configureRequest } from '../../../core/shared/operators';
import { RouteService } from '../../../shared/services/route.service';
import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models';
/**
* Service for performing actions on the filtered-discovery-pages REST endpoint
*/
@Injectable()
export class SearchFixedFilterService {
private queryByFilterPath = 'filtered-discovery-pages';
constructor(private routeService: RouteService,
protected requestService: RequestService,
private halService: HALEndpointService) {
}
/**
* Get the filter query for a certain filter by name
* @param {string} filterName Name of the filter
* @returns {Observable<string>} Filter query
*/
getQueryByFilterName(filterName: string): Observable<string> {
if (hasValue(filterName)) {
const requestUuid = this.requestService.generateRequestId();
const requestObs = this.halService.getEndpoint(this.queryByFilterPath).pipe(
map((url: string) => {
url += ('/' + filterName);
const request = new GetRequest(requestUuid, url);
return Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return FilteredDiscoveryPageResponseParsingService;
}
});
}),
configureRequest(this.requestService)
);
// get search results from response cache
const filterQuery: Observable<string> = this.requestService.getByUUID(requestUuid).pipe(
map((response: FilteredDiscoveryQueryResponse) =>
response.filterQuery
));
return filterQuery;
}
return observableOf(undefined);
}
/**
* Get the query for looking up items by relation type
* @param {string} relationType Relation type
* @param {string} itemUUID Item UUID
* @returns {string} Query
*/
getQueryByRelations(relationType: string, itemUUID: string): string {
return `query=relation.${relationType}:${itemUUID}`;
}
/**
* Get the filter for a relation with the item's UUID
* @param relationType The type of relation e.g. 'isAuthorOfPublication'
* @param itemUUID The item's UUID
*/
getFilterByRelation(relationType: string, itemUUID: string): string {
return `f.${relationType}=${itemUUID}`;
}
}

View File

@@ -3,21 +3,25 @@ import { URLCombiner } from '../core/url-combiner/url-combiner';
import 'core-js/library/fn/object/entries'; import 'core-js/library/fn/object/entries';
import { SearchFilter } from './search-filter.model'; import { SearchFilter } from './search-filter.model';
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
import { SetViewMode } from '../shared/view-mode';
/** /**
* This model class represents all parameters needed to request information about a certain search request * This model class represents all parameters needed to request information about a certain search request
*/ */
export class SearchOptions { export class SearchOptions {
view?: SetViewMode = SetViewMode.List;
scope?: string; scope?: string;
query?: string; query?: string;
dsoType?: DSpaceObjectType; dsoType?: DSpaceObjectType;
filters?: SearchFilter[]; filters?: any;
fixedFilter?: any;
constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[]}) { constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], fixedFilter?: any}) {
this.scope = options.scope; this.scope = options.scope;
this.query = options.query; this.query = options.query;
this.dsoType = options.dsoType; this.dsoType = options.dsoType;
this.filters = options.filters; this.filters = options.filters;
this.fixedFilter = options.fixedFilter;
} }
/** /**
@@ -27,7 +31,9 @@ export class SearchOptions {
* @returns {string} URL with all search options and passed arguments as query parameters * @returns {string} URL with all search options and passed arguments as query parameters
*/ */
toRestUrl(url: string, args: string[] = []): string { toRestUrl(url: string, args: string[] = []): string {
if (isNotEmpty(this.fixedFilter)) {
args.push(this.fixedFilter);
}
if (isNotEmpty(this.query)) { if (isNotEmpty(this.query)) {
args.push(`query=${this.query}`); args.push(`query=${this.query}`);
} }

View File

@@ -2,11 +2,14 @@ import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { SearchPageComponent } from './search-page.component'; import { SearchPageComponent } from './search-page.component';
import { FilteredSearchPageComponent } from './filtered-search-page.component';
import { FilteredSearchPageGuard } from './filtered-search-page.guard';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ path: '', component: SearchPageComponent, data: { title: 'search.title' } } { path: '', component: SearchPageComponent, data: { title: 'search.title' } },
{ path: ':filter', component: FilteredSearchPageComponent, canActivate: [FilteredSearchPageGuard], data: { title: 'search.' }}
]) ])
] ]
}) })

View File

@@ -1,16 +1,16 @@
<div class="container"> <div class="container">
<div class="search-page row"> <div class="search-page row">
<ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky" <ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-{{sideBarWidth}} sidebar-md-sticky"
id="search-sidebar" id="search-sidebar"
[resultCount]="(resultsRD$ | async)?.payload.totalElements"></ds-search-sidebar> [resultCount]="(resultsRD$ | async)?.payload.totalElements"></ds-search-sidebar>
<div class="col-12 col-md-9"> <div class="col-12 col-md-{{12 - sideBarWidth}}">
<ds-search-form id="search-form" <ds-search-form *ngIf="searchEnabled" id="search-form"
[query]="(searchOptions$ | async)?.query" [query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope" [scope]="(searchOptions$ | async)?.scope"
[currentUrl]="getSearchLink()" [currentUrl]="getSearchLink()"
[scopes]="(scopeListRD$ | async)"> [scopes]="(scopeListRD$ | async)">
</ds-search-form> </ds-search-form>
<ds-search-labels></ds-search-labels> <ds-search-labels *ngIf="searchEnabled"></ds-search-labels>
<div class="row"> <div class="row">
<div id="search-body" <div id="search-body"
class="row-offcanvas row-offcanvas-left" class="row-offcanvas row-offcanvas-left"
@@ -31,7 +31,9 @@
</button> </button>
</div> </div>
<ds-search-results [searchResults]="resultsRD$ | async" <ds-search-results [searchResults]="resultsRD$ | async"
[searchConfig]="searchOptions$ | async"></ds-search-results> [searchConfig]="searchOptions$ | async"
[fixedFilter]="fixedFilter$ | async"
[disableHeader]="!searchEnabled"></ds-search-results>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -20,91 +20,114 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
import { SearchConfigurationService } from './search-service/search-configuration.service'; import { SearchConfigurationService } from './search-service/search-configuration.service';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
import { RouteService } from '../shared/services/route.service';
let comp: SearchPageComponent;
let fixture: ComponentFixture<SearchPageComponent>;
let searchServiceObject: SearchService;
const store: Store<SearchPageComponent> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: observableOf(true)
});
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
pagination.id = 'search-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data']));
const searchServiceStub = jasmine.createSpyObj('SearchService', {
search: mockResults,
getSearchLink: '/search',
getScopes: observableOf(['test-scope'])
});
const queryParam = 'test query';
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
const fixedFilter = 'fixed filter';
const paginatedSearchOptions = {
query: queryParam,
scope: scopeParam,
fixedFilter: fixedFilter,
pagination,
sort
};
const activatedRouteStub = {
queryParams: observableOf({
query: queryParam,
scope: scopeParam
})
};
const sidebarService = {
isCollapsed: observableOf(true),
collapse: () => this.isCollapsed = observableOf(true),
expand: () => this.isCollapsed = observableOf(false)
};
const routeServiceStub = {
getRouteParameterValue: () => {
return observableOf('');
}
};
export function configureSearchComponentTestingModule(compType) {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()],
declarations: [compType],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
{
provide: CommunityDataService,
useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll'])
},
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{
provide: Store, useValue: store
},
{
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
{
isXs: observableOf(true),
isSm: observableOf(false),
isXsOrSm: observableOf(true)
})
},
{
provide: SearchSidebarService,
useValue: sidebarService
},
{
provide: SearchFilterService,
useValue: {}
},
{
provide: SearchConfigurationService,
useValue: {
paginatedSearchOptions: hot('a', {
a: paginatedSearchOptions
}),
getCurrentScope: (a) => observableOf('test-id'),
/* tslint:disable:no-empty */
updateFixedFilter: (newFilter) => {
}
/* tslint:enable:no-empty */
}
},
{
provide: RouteService,
useValue: routeServiceStub
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(compType, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}
describe('SearchPageComponent', () => { describe('SearchPageComponent', () => {
let comp: SearchPageComponent;
let fixture: ComponentFixture<SearchPageComponent>;
let searchServiceObject: SearchService;
const store: Store<SearchPageComponent> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: observableOf(true)
});
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
pagination.id = 'search-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data']));
const searchServiceStub = jasmine.createSpyObj('SearchService', {
search: mockResults,
getSearchLink: '/search',
getScopes: observableOf(['test-scope'])
});
const queryParam = 'test query';
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
const paginatedSearchOptions = {
query: queryParam,
scope: scopeParam,
pagination,
sort
};
const activatedRouteStub = {
queryParams: observableOf({
query: queryParam,
scope: scopeParam
})
};
const sidebarService = {
isCollapsed: observableOf(true),
collapse: () => this.isCollapsed = observableOf(true),
expand: () => this.isCollapsed = observableOf(false)
};
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ configureSearchComponentTestingModule(SearchPageComponent);
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule.forRoot()],
declarations: [SearchPageComponent],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
{
provide: CommunityDataService,
useValue: jasmine.createSpyObj('communityService', ['findById', 'findAll'])
},
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{
provide: Store, useValue: store
},
{
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
{
isXs: observableOf(true),
isSm: observableOf(false),
isXsOrSm: observableOf(true)
})
},
{
provide: SearchSidebarService,
useValue: sidebarService
},
{
provide: SearchFilterService,
useValue: {}
}, {
provide: SearchConfigurationService,
useValue: {
paginatedSearchOptions: hot('a', {
a: paginatedSearchOptions
}),
getCurrentScope: (a) => observableOf('test-id')
}
},
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchPageComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {
@@ -177,4 +200,4 @@ describe('SearchPageComponent', () => {
}); });
}); });
}) });

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { Observable , Subscription , BehaviorSubject } from 'rxjs';
import { switchMap, } from 'rxjs/operators'; import { switchMap, } from 'rxjs/operators';
import { PaginatedList } from '../core/data/paginated-list'; import { PaginatedList } from '../core/data/paginated-list';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
@@ -11,15 +11,10 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
import { SearchResult } from './search-result.model'; import { SearchResult } from './search-result.model';
import { SearchService } from './search-service/search.service'; import { SearchService } from './search-service/search.service';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { hasValue } from '../shared/empty.util'; import { hasValue, isNotEmpty } from '../shared/empty.util';
import { SearchConfigurationService } from './search-service/search-configuration.service'; import { SearchConfigurationService } from './search-service/search-configuration.service';
import { getSucceededRemoteData } from '../core/shared/operators'; import { getSucceededRemoteData } from '../core/shared/operators';
import { RouteService } from '../shared/services/route.service';
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
@Component({ @Component({
selector: 'ds-search-page', selector: 'ds-search-page',
@@ -31,6 +26,7 @@ import { getSucceededRemoteData } from '../core/shared/operators';
/** /**
* This component represents the whole search page * This component represents the whole search page
* It renders search results depending on the current search options
*/ */
export class SearchPageComponent implements OnInit { export class SearchPageComponent implements OnInit {
@@ -59,11 +55,30 @@ export class SearchPageComponent implements OnInit {
*/ */
sub: Subscription; sub: Subscription;
constructor(private service: SearchService, /**
private sidebarService: SearchSidebarService, * Whether or not the search bar should be visible
private windowService: HostWindowService, */
private filterService: SearchFilterService, @Input()
private searchConfigService: SearchConfigurationService) { searchEnabled = true;
/**
* The width of the sidebar (bootstrap columns)
*/
@Input()
sideBarWidth = 3;
/**
* The currently applied filter (determines title of search)
*/
@Input()
fixedFilter$: Observable<string>;
constructor(protected service: SearchService,
protected sidebarService: SearchSidebarService,
protected windowService: HostWindowService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected routeService: RouteService) {
this.isXsOrSm$ = this.windowService.isXsOrSm(); this.isXsOrSm$ = this.windowService.isXsOrSm();
} }
@@ -75,7 +90,7 @@ export class SearchPageComponent implements OnInit {
* If something changes, update the list of scopes for the dropdown * If something changes, update the list of scopes for the dropdown
*/ */
ngOnInit(): void { ngOnInit(): void {
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; this.searchOptions$ = this.getSearchOptions();
this.sub = this.searchOptions$.pipe( this.sub = this.searchOptions$.pipe(
switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))) switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData())))
.subscribe((results) => { .subscribe((results) => {
@@ -84,6 +99,17 @@ export class SearchPageComponent implements OnInit {
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
switchMap((scopeId) => this.service.getScopes(scopeId)) switchMap((scopeId) => this.service.getScopes(scopeId))
); );
if (!isNotEmpty(this.fixedFilter$)) {
this.fixedFilter$ = this.routeService.getRouteParameterValue('filter');
}
}
/**
* Get the current paginated search options
* @returns {Observable<PaginatedSearchOptions>}
*/
protected getSearchOptions(): Observable<PaginatedSearchOptions> {
return this.searchConfigService.paginatedSearchOptions;
} }
/** /**

View File

@@ -21,6 +21,9 @@ import { SearchFiltersComponent } from './search-filters/search-filters.componen
import { SearchFilterComponent } from './search-filters/search-filter/search-filter.component'; import { SearchFilterComponent } from './search-filters/search-filter/search-filter.component';
import { SearchFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component'; import { SearchFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component';
import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
import { FilteredSearchPageComponent } from './filtered-search-page.component';
import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service';
import { FilteredSearchPageGuard } from './filtered-search-page.guard';
import { SearchLabelsComponent } from './search-labels/search-labels.component'; import { SearchLabelsComponent } from './search-labels/search-labels.component';
import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component'; import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component';
import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component'; import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component';
@@ -43,6 +46,7 @@ const effects = [
], ],
declarations: [ declarations: [
SearchPageComponent, SearchPageComponent,
FilteredSearchPageComponent,
SearchResultsComponent, SearchResultsComponent,
SearchSidebarComponent, SearchSidebarComponent,
SearchSettingsComponent, SearchSettingsComponent,
@@ -68,6 +72,9 @@ const effects = [
SearchService, SearchService,
SearchSidebarService, SearchSidebarService,
SearchFilterService, SearchFilterService,
SearchFixedFilterService,
FilteredSearchPageGuard,
SearchFilterService,
SearchConfigurationService SearchConfigurationService
], ],
entryComponents: [ entryComponents: [
@@ -82,6 +89,9 @@ const effects = [
SearchTextFilterComponent, SearchTextFilterComponent,
SearchHierarchyFilterComponent, SearchHierarchyFilterComponent,
SearchBooleanFilterComponent, SearchBooleanFilterComponent,
],
exports: [
FilteredSearchPageComponent,
] ]
}) })

View File

@@ -1,4 +1,4 @@
<h2>{{ 'search.results.head' | translate }}</h2> <h2 *ngIf="!disableHeader">{{ getTitleKey() | translate }}</h2>
<div *ngIf="searchResults?.hasSucceeded && !searchResults?.isLoading && searchResults?.payload?.page.length > 0" @fadeIn> <div *ngIf="searchResults?.hasSucceeded && !searchResults?.isLoading && searchResults?.payload?.page.length > 0" @fadeIn>
<ds-viewable-collection <ds-viewable-collection
[config]="searchConfig.pagination" [config]="searchConfig.pagination"

View File

@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed, async, tick, fakeAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, async, tick, fakeAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ResourceType } from '../../core/shared/resource-type'; import { ResourceType } from '../../core/shared/resource-type';
import { Community } from '../../core/shared/community.model'; import { Community } from '../../core/shared/community.model';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -11,6 +11,8 @@ import { QueryParamsDirectiveStub } from '../../shared/testing/query-params-dire
describe('SearchResultsComponent', () => { describe('SearchResultsComponent', () => {
let comp: SearchResultsComponent; let comp: SearchResultsComponent;
let fixture: ComponentFixture<SearchResultsComponent>; let fixture: ComponentFixture<SearchResultsComponent>;
let heading: DebugElement;
let title: DebugElement;
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -24,7 +26,9 @@ describe('SearchResultsComponent', () => {
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(SearchResultsComponent); fixture = TestBed.createComponent(SearchResultsComponent);
comp = fixture.componentInstance; // SearchResultsComponent test instance comp = fixture.componentInstance; // SearchFormComponent test instance
heading = fixture.debugElement.query(By.css('heading'));
title = fixture.debugElement.query(By.css('h2'));
}); });
it('should display results when results are not empty', () => { it('should display results when results are not empty', () => {

View File

@@ -2,11 +2,12 @@ import { Component, Input } from '@angular/core';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { fadeIn, fadeInOut } from '../../shared/animations/fade'; import { fadeIn, fadeInOut } from '../../shared/animations/fade';
import { SetViewMode } from '../../shared/view-mode';
import { SearchOptions } from '../search-options.model'; import { SearchOptions } from '../search-options.model';
import { SearchResult } from '../search-result.model'; import { SearchResult } from '../search-result.model';
import { PaginatedList } from '../../core/data/paginated-list'; import { PaginatedList } from '../../core/data/paginated-list';
import { ViewMode } from '../../core/shared/view-mode.model';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { SortOptions } from '../../core/cache/models/sort-options.model';
@Component({ @Component({
selector: 'ds-search-results', selector: 'ds-search-results',
@@ -30,11 +31,23 @@ export class SearchResultsComponent {
* The current configuration of the search * The current configuration of the search
*/ */
@Input() searchConfig: SearchOptions; @Input() searchConfig: SearchOptions;
@Input() sortConfig: SortOptions;
@Input() viewMode: SetViewMode;
@Input() fixedFilter: string;
@Input() disableHeader = false;
/** /**
* The current view mode for the search results * Get the i18n key for the title depending on the fixed filter
* Defaults to 'search.results.head' if there's no fixed filter found
* @returns {string}
*/ */
@Input() viewMode: ViewMode; getTitleKey() {
if (isNotEmpty(this.fixedFilter)) {
return 'search.' + this.fixedFilter + '.results.head'
} else {
return 'search.results.head';
}
}
/** /**
* Method to change the given string by surrounding it by quotes if not already present. * Method to change the given string by surrounding it by quotes if not already present.

View File

@@ -23,17 +23,21 @@ describe('SearchConfigurationService', () => {
const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])]; const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])];
const spy = jasmine.createSpyObj('RouteService', { const routeService = jasmine.createSpyObj('RouteService', {
getQueryParameterValue: observableOf(value1), getQueryParameterValue: observableOf(value1),
getQueryParamsWithPrefix: observableOf(prefixFilter) getQueryParamsWithPrefix: observableOf(prefixFilter),
getRouteParameterValue: observableOf('')
});
const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', {
getQueryByFilterName: observableOf(''),
}); });
const activatedRoute: any = new ActivatedRouteStub(); const activatedRoute: any = new ActivatedRouteStub();
beforeEach(() => { beforeEach(() => {
service = new SearchConfigurationService(spy, activatedRoute); service = new SearchConfigurationService(routeService, fixedFilterService, activatedRoute);
}); });
describe('when the scope is called', () => { describe('when the scope is called', () => {
beforeEach(() => { beforeEach(() => {
service.getCurrentScope(''); service.getCurrentScope('');
@@ -143,4 +147,29 @@ describe('SearchConfigurationService', () => {
}); });
}); });
}); });
describe('when getCurrentFixedFilter is called', () => {
beforeEach(() => {
service.getCurrentFixedFilter();
});
it('should call getRouteParameterValue on the routeService with parameter name \'filter\'', () => {
expect((service as any).routeService.getRouteParameterValue).toHaveBeenCalledWith('filter');
});
});
describe('when updateFixedFilter is called', () => {
const filter = 'filter';
beforeEach(() => {
service.updateFixedFilter(filter);
});
it('should update the paginated search options with the correct fixed filter', () => {
expect(service.paginatedSearchOptions.getValue().fixedFilter).toEqual(filter);
});
it('should update the search options with the correct fixed filter', () => {
expect(service.searchOptions.getValue().fixedFilter).toEqual(filter);
});
});
}); });

View File

@@ -6,7 +6,7 @@ import {
of as observableOf, of as observableOf,
Subscription Subscription
} from 'rxjs'; } from 'rxjs';
import { filter, map } from 'rxjs/operators'; import { filter, flatMap, map } from 'rxjs/operators';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SearchOptions } from '../search-options.model'; import { SearchOptions } from '../search-options.model';
@@ -14,11 +14,12 @@ import { ActivatedRoute, Params } from '@angular/router';
import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { Injectable, OnDestroy } from '@angular/core'; import { Injectable, OnDestroy } from '@angular/core';
import { RouteService } from '../../shared/services/route.service'; import { RouteService } from '../../shared/services/route.service';
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { getSucceededRemoteData } from '../../core/shared/operators'; import { getSucceededRemoteData } from '../../core/shared/operators';
import { SearchFilter } from '../search-filter.model'; import { SearchFilter } from '../search-filter.model';
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
import { SearchFixedFilterService } from '../search-filters/search-filter/search-fixed-filter.service';
/** /**
* Service that performs all actions that have to do with the current search configuration * Service that performs all actions that have to do with the current search configuration
@@ -75,6 +76,7 @@ export class SearchConfigurationService implements OnDestroy {
* @param {ActivatedRoute} route * @param {ActivatedRoute} route
*/ */
constructor(private routeService: RouteService, constructor(private routeService: RouteService,
private fixedFilterService: SearchFixedFilterService,
private route: ActivatedRoute) { private route: ActivatedRoute) {
this.defaults this.defaults
.pipe(getSucceededRemoteData()) .pipe(getSucceededRemoteData())
@@ -174,6 +176,15 @@ export class SearchConfigurationService implements OnDestroy {
})); }));
} }
/**
* @returns {Observable<string>} Emits the current fixed filter as a string
*/
getCurrentFixedFilter(): Observable<string> {
return this.routeService.getRouteParameterValue('filter').pipe(
flatMap((f) => this.fixedFilterService.getQueryByFilterName(f))
);
}
/** /**
* @returns {Observable<Params>} Emits the current active filters with their values as they are displayed in the frontend URL * @returns {Observable<Params>} Emits the current active filters with their values as they are displayed in the frontend URL
*/ */
@@ -191,7 +202,8 @@ export class SearchConfigurationService implements OnDestroy {
this.getScopePart(defaults.scope), this.getScopePart(defaults.scope),
this.getQueryPart(defaults.query), this.getQueryPart(defaults.query),
this.getDSOTypePart(), this.getDSOTypePart(),
this.getFiltersPart() this.getFiltersPart(),
this.getFixedFilterPart()
).subscribe((update) => { ).subscribe((update) => {
const currentValue: SearchOptions = this.searchOptions.getValue(); const currentValue: SearchOptions = this.searchOptions.getValue();
const updatedValue: SearchOptions = Object.assign(currentValue, update); const updatedValue: SearchOptions = Object.assign(currentValue, update);
@@ -211,7 +223,8 @@ export class SearchConfigurationService implements OnDestroy {
this.getScopePart(defaults.scope), this.getScopePart(defaults.scope),
this.getQueryPart(defaults.query), this.getQueryPart(defaults.query),
this.getDSOTypePart(), this.getDSOTypePart(),
this.getFiltersPart() this.getFiltersPart(),
this.getFixedFilterPart()
).subscribe((update) => { ).subscribe((update) => {
const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue();
const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update); const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update);
@@ -297,4 +310,30 @@ export class SearchConfigurationService implements OnDestroy {
return { filters } return { filters }
})); }));
} }
/**
* @returns {Observable<string>} Emits the current fixed filter as a partial SearchOptions object
*/
private getFixedFilterPart(): Observable<any> {
return this.getCurrentFixedFilter().pipe(
isNotEmptyOperator(),
map((fixedFilter) => {
return { fixedFilter }
})
);
}
/**
* Update the fixed filter in paginated and non-paginated search options with a given value
* @param {string} fixedFilter
*/
public updateFixedFilter(fixedFilter: string) {
const currentPaginatedValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue();
const updatedPaginatedValue: PaginatedSearchOptions = Object.assign(currentPaginatedValue, { fixedFilter: fixedFilter });
this.paginatedSearchOptions.next(updatedPaginatedValue);
const currentValue: SearchOptions = this.searchOptions.getValue();
const updatedValue: SearchOptions = Object.assign(currentValue, { fixedFilter: fixedFilter });
this.searchOptions.next(updatedValue);
}
} }

View File

@@ -5,6 +5,9 @@ import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { SearchService } from './search.service'; import { SearchService } from './search.service';
import { ItemDataService } from './../../core/data/item-data.service';
import { SetViewMode } from '../../shared/view-mode';
import { GLOBAL_CONFIG } from '../../../config';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { ActivatedRoute, Router, UrlTree } from '@angular/router';
import { RequestService } from '../../core/data/request.service'; import { RequestService } from '../../core/data/request.service';
@@ -64,7 +67,7 @@ describe('SearchService', () => {
it('should return list view mode', () => { it('should return list view mode', () => {
searchService.getViewMode().subscribe((viewMode) => { searchService.getViewMode().subscribe((viewMode) => {
expect(viewMode).toBe(ViewMode.List); expect(viewMode).toBe(SetViewMode.List);
}); });
}); });
}); });
@@ -122,33 +125,33 @@ describe('SearchService', () => {
}); });
it('should call the navigate method on the Router with view mode list parameter as a parameter when setViewMode is called', () => { it('should call the navigate method on the Router with view mode list parameter as a parameter when setViewMode is called', () => {
searchService.setViewMode(ViewMode.List); searchService.setViewMode(SetViewMode.List);
expect(router.navigate).toHaveBeenCalledWith(['/search'], { expect(router.navigate).toHaveBeenCalledWith(['/search'], {
queryParams: { view: ViewMode.List }, queryParams: { view: SetViewMode.List },
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
}); });
it('should call the navigate method on the Router with view mode grid parameter as a parameter when setViewMode is called', () => { it('should call the navigate method on the Router with view mode grid parameter as a parameter when setViewMode is called', () => {
searchService.setViewMode(ViewMode.Grid); searchService.setViewMode(SetViewMode.Grid);
expect(router.navigate).toHaveBeenCalledWith(['/search'], { expect(router.navigate).toHaveBeenCalledWith(['/search'], {
queryParams: { view: ViewMode.Grid }, queryParams: { view: SetViewMode.Grid },
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
}); });
it('should return ViewMode.List when the viewMode is set to ViewMode.List in the ActivatedRoute', () => { it('should return ViewMode.List when the viewMode is set to ViewMode.List in the ActivatedRoute', () => {
let viewMode = ViewMode.Grid; let viewMode = SetViewMode.Grid;
route.testParams = { view: ViewMode.List }; route.testParams = { view: SetViewMode.List };
searchService.getViewMode().subscribe((mode) => viewMode = mode); searchService.getViewMode().subscribe((mode) => viewMode = mode);
expect(viewMode).toEqual(ViewMode.List); expect(viewMode).toEqual(SetViewMode.List);
}); });
it('should return ViewMode.Grid when the viewMode is set to ViewMode.Grid in the ActivatedRoute', () => { it('should return ViewMode.Grid when the viewMode is set to ViewMode.Grid in the ActivatedRoute', () => {
let viewMode = ViewMode.List; let viewMode = SetViewMode.List;
route.testParams = { view: ViewMode.Grid }; route.testParams = { view: SetViewMode.Grid };
searchService.getViewMode().subscribe((mode) => viewMode = mode); searchService.getViewMode().subscribe((mode) => viewMode = mode);
expect(viewMode).toEqual(ViewMode.Grid); expect(viewMode).toEqual(SetViewMode.Grid);
}); });
describe('when search is called', () => { describe('when search is called', () => {

Some files were not shown because too many files have changed in this diff Show More