diff --git a/config/environment.default.js b/config/environment.default.js index b8eae1ccff..b27ef403ad 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -10,10 +10,10 @@ module.exports = { // The REST API server settings. rest: { ssl: true, - host: 'dspace7.4science.it', + host: 'dspace7-entities.atmire.com', port: 443, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/dspace-spring-rest/api' + nameSpace: '/rest/api' }, // Caching settings cache: { diff --git a/e2e/app.e2e-spec.ts b/e2e/app.e2e-spec.ts index f5ac9094d0..6856a6f01b 100644 --- a/e2e/app.e2e-spec.ts +++ b/e2e/app.e2e-spec.ts @@ -12,8 +12,8 @@ describe('protractor App', () => { expect(page.getPageTitleText()).toEqual('DSpace Angular :: Home'); }); - it('should display header "Welcome to DSpace"', () => { + it('should contain a news section', () => { page.navigateTo(); - expect(page.getFirstHeaderText()).toEqual('Welcome to DSpace'); + expect(page.getHomePageNewsText()).toBeDefined(); }); }); diff --git a/e2e/app.po.ts b/e2e/app.po.ts index d8d2acf120..54b5b55af3 100644 --- a/e2e/app.po.ts +++ b/e2e/app.po.ts @@ -9,11 +9,7 @@ export class ProtractorPage { return browser.getTitle(); } - getFirstPText() { - return element(by.xpath('//p[1]')).getText(); - } - - getFirstHeaderText() { - return element(by.xpath('//h1[1]')).getText(); + getHomePageNewsText() { + return element(by.xpath('//ds-home-news')).getText(); } } diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 208e5415a8..ef05168f19 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -91,12 +91,14 @@ }, "item": { "page": { - "author": "Author", + "author": "Authors", "abstract": "Abstract", "date": "Date", "uri": "URI", "files": "Files", "collections": "Collections", + "subject": "Keywords", + "citation": "Citation", "filesection": { "download": "Download", "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": { "browse": { "header": "All of DSpace" @@ -319,6 +399,24 @@ } }, "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", "description": "", "form": { @@ -355,7 +453,8 @@ "f.dateIssued.min": "Start date", "f.dateIssued.max": "End date", "f.subject": "Subject", - "f.has_content_in_original_bundle": "Has files" + "f.has_content_in_original_bundle": "Has files", + "f.entityType": "Item Type" }, "filter": { "show-more": "Show more", @@ -383,6 +482,10 @@ }, "has_content_in_original_bundle": { "head": "Has files" + }, + "entityType": { + "placeholder": "Item Type", + "head": "Item Type" } } } diff --git a/resources/images/orgunit-placeholder.jpg b/resources/images/orgunit-placeholder.jpg new file mode 100644 index 0000000000..11564bc635 Binary files /dev/null and b/resources/images/orgunit-placeholder.jpg differ diff --git a/resources/images/person-placeholder.png b/resources/images/person-placeholder.png new file mode 100644 index 0000000000..eb6232e00f Binary files /dev/null and b/resources/images/person-placeholder.png differ diff --git a/resources/images/project-placeholder.png b/resources/images/project-placeholder.png new file mode 100644 index 0000000000..df75c8b2db Binary files /dev/null and b/resources/images/project-placeholder.png differ diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 7c4f2b92ac..c1ef2522d1 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,8 +1,9 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; 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 { CollectionDataService } from '../core/data/collection-data.service'; +import { ItemDataService } from '../core/data/item-data.service'; import { PaginatedList } from '../core/data/paginated-list'; 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 { hasValue, isNotEmpty } from '../shared/empty.util'; 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 { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; import { toDSpaceObjectListRD } from '../core/shared/operators'; @@ -42,7 +43,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy { constructor( private collectionDataService: CollectionDataService, - private searchService: SearchService, + private itemDataService: ItemDataService, private metadata: MetadataService, private route: ActivatedRoute ) { @@ -56,7 +57,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy { ngOnInit(): void { this.collectionRD$ = this.route.data.pipe( map((data) => data.collection), - tap((data) => this.collectionId = data.payload.id) + first() ); this.logoRD$ = this.collectionRD$.pipe( map((rd: RemoteData) => rd.payload), @@ -68,26 +69,33 @@ export class CollectionPageComponent implements OnInit, OnDestroy { this.metadata.processRemoteData(this.collectionRD$); const page = +params.page || this.paginationConfig.currentPage; const pageSize = +params.pageSize || this.paginationConfig.pageSize; + const sortDirection = +params.page || this.sortConfig.direction; const pagination = Object.assign({}, this.paginationConfig, { currentPage: page, pageSize: pageSize } ); - this.updatePage({ - pagination: pagination, - sort: this.sortConfig + const sort = Object.assign({}, + this.sortConfig, + { direction: sortDirection, field: params.sortField } + ); + this.collectionRD$.subscribe((rd: RemoteData) => { + this.collectionId = rd.payload.id; + this.updatePage({ + pagination: pagination, + sort: sort + }); }); - })); - + }) + ); } updatePage(searchOptions) { - this.itemRD$ = this.searchService.search( - new PaginatedSearchOptions({ - scope: this.collectionId, - pagination: searchOptions.pagination, - sort: searchOptions.sort, - dsoType: DSpaceObjectType.ITEM - })).pipe(toDSpaceObjectListRD()) as Observable>>; + this.itemRD$ = this.itemDataService.findAll({ + scopeID: this.collectionId, + currentPage: searchOptions.pagination.currentPage, + elementsPerPage: searchOptions.pagination.pageSize, + sort: searchOptions.sort + }); } ngOnDestroy(): void { diff --git a/src/app/+collection-page/collection-page.module.ts b/src/app/+collection-page/collection-page.module.ts index 8424cc02a4..bdeffa34f3 100644 --- a/src/app/+collection-page/collection-page.module.ts +++ b/src/app/+collection-page/collection-page.module.ts @@ -7,15 +7,14 @@ import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageRoutingModule } from './collection-page-routing.module'; import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.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 { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; +import { SearchService } from '../+search-page/search-service/search.service'; @NgModule({ imports: [ CommonModule, SharedModule, - SearchPageModule, CollectionPageRoutingModule ], declarations: [ @@ -24,6 +23,9 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c EditCollectionPageComponent, DeleteCollectionPageComponent, CollectionFormComponent + ], + providers: [ + SearchService ] }) export class CollectionPageModule { diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html index bbe6d8d95b..c791cec600 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html @@ -1,4 +1,4 @@ -
+
{{ label }}
diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts index cce54edf64..d7e1b80c76 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts @@ -1,18 +1,41 @@ -import { ComponentFixture, TestBed, async } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { Component, DebugElement } from '@angular/core'; +import { Component } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component'; +/* tslint:disable:max-classes-per-file */ @Component({ - selector: 'ds-component-with-content', + selector: 'ds-component-without-content', template: '\n' + - '
\n' + - ' \n' + - '
\n' + '
' }) -class ContentComponent {} +class NoContentComponent {} + +@Component({ + selector: 'ds-component-with-empty-spans', + template: '\n' + + ' \n' + + ' \n' + + '' +}) +class SpanContentComponent {} + +@Component({ + selector: 'ds-component-with-text', + template: '\n' + + ' The quick brown fox jumps over the lazy dog\n' + + '' +}) +class TextContentComponent {} + +@Component({ + selector: 'ds-component-with-image', + template: '\n' + + ' an alt text\n' + + '' +}) +class ImgContentComponent {} +/* tslint:enable:max-classes-per-file */ describe('MetadataFieldWrapperComponent', () => { let component: MetadataFieldWrapperComponent; @@ -20,7 +43,7 @@ describe('MetadataFieldWrapperComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [MetadataFieldWrapperComponent, ContentComponent] + declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent, ImgContentComponent] }).compileComponents(); })); @@ -30,23 +53,21 @@ describe('MetadataFieldWrapperComponent', () => { }); const wrapperSelector = '.simple-view-element'; - const labelSelector = '.simple-view-element-header'; - const contentSelector = '.my-content'; it('should create', () => { expect(component).toBeDefined(); }); it('should not show the component when there is no content', () => { - component.label = 'test label'; - fixture.detectChanges(); - const parentNative = fixture.nativeElement; + const parentFixture = TestBed.createComponent(NoContentComponent); + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; const nativeWrapper = parentNative.querySelector(wrapperSelector); expect(nativeWrapper.classList.contains('d-none')).toBe(true); }); - it('should not show the component when there is DOM content but no text', () => { - const parentFixture = TestBed.createComponent(ContentComponent); + it('should not show the component when there is DOM content but not text or an image', () => { + const parentFixture = TestBed.createComponent(SpanContentComponent); parentFixture.detectChanges(); const parentNative = parentFixture.nativeElement; const nativeWrapper = parentNative.querySelector(wrapperSelector); @@ -54,11 +75,18 @@ describe('MetadataFieldWrapperComponent', () => { }); 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(); const parentNative = parentFixture.nativeElement; - const nativeContent = parentNative.querySelector(contentSelector); - nativeContent.textContent = 'lorem ipsum'; const nativeWrapper = parentNative.querySelector(wrapperSelector); parentFixture.detectChanges(); expect(nativeWrapper.classList.contains('d-none')).toBe(false); diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts index 8c80384732..8af108cceb 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts @@ -1,4 +1,5 @@ import { Component, Input } from '@angular/core'; +import { hasNoValue } from '../../../shared/empty.util'; /** * This component renders any content inside this wrapper. @@ -11,6 +12,15 @@ import { Component, Input } from '@angular/core'; }) export class MetadataFieldWrapperComponent { + /** + * The label (title) for the content + */ @Input() label: string; + /** + * Make hasNoValue() available in the template + */ + hasNoValue(o: any): boolean { + return hasNoValue(o); + } } diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts new file mode 100644 index 0000000000..d12a73a885 --- /dev/null +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts @@ -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; + +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 = '
'; +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; +} diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts index 67684d44af..e070eccf2d 100644 --- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts @@ -17,11 +17,24 @@ import { MetadataValue } from '../../../core/shared/metadata.models'; }) export class MetadataUriValuesComponent extends MetadataValuesComponent { + /** + * Optional text to replace the links with + * If undefined, the metadata value (uri) is displayed + */ @Input() linktext: any; + /** + * The metadata values to display + */ @Input() mdValues: MetadataValue[]; + /** + * The seperator used to split the metadata values (can contain HTML) + */ @Input() separator: string; + /** + * The label for this iteration of metadata values + */ @Input() label: string; } diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts new file mode 100644 index 0000000000..9682386b96 --- /dev/null +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.spec.ts @@ -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; + +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 = '
'; +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); + }); + +}); diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts index abcd90848d..142b08b360 100644 --- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts @@ -12,10 +12,19 @@ import { MetadataValue } from '../../../core/shared/metadata.models'; }) export class MetadataValuesComponent { + /** + * The metadata values to display + */ @Input() mdValues: MetadataValue[]; + /** + * The seperator used to split the metadata values (can contain HTML) + */ @Input() separator: string; + /** + * The label for this iteration of metadata values + */ @Input() label: string; } diff --git a/src/app/+item-page/full/full-item-page.component.spec.ts b/src/app/+item-page/full/full-item-page.component.spec.ts new file mode 100644 index 0000000000..3377dec6db --- /dev/null +++ b/src/app/+item-page/full/full-item-page.component.spec.ts @@ -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; + + 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); + } + }) +}); diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index 6e19a50864..dd4179a8c8 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -1,9 +1,8 @@ - import {filter, map} from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Observable , BehaviorSubject } from 'rxjs'; import { ItemPageComponent } from '../simple/item-page.component'; import { MetadataMap } from '../../core/shared/metadata.models'; @@ -32,7 +31,7 @@ import { hasValue } from '../../shared/empty.util'; }) export class FullItemPageComponent extends ItemPageComponent implements OnInit { - itemRD$: Observable>; + itemRD$: BehaviorSubject>; metadata$: Observable; diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index d383189a9c..0c4ccc868b 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; 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 { 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 { 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 { 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 { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.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 { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component'; +import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component'; @NgModule({ imports: [ CommonModule, SharedModule, EditItemPageModule, - ItemPageRoutingModule + ItemPageRoutingModule, + SearchPageModule ], declarations: [ ItemPageComponent, @@ -38,10 +52,31 @@ import { EditItemPageModule } from './edit-item-page/edit-item-page.module'; ItemPageAbstractFieldComponent, ItemPageUriFieldComponent, ItemPageTitleFieldComponent, - ItemPageSpecificFieldComponent, + ItemPageFieldComponent, FileSectionComponent, 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 { diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index c0ee6a84ee..9743346c3c 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -5,6 +5,7 @@ import { RemoteData } from '../core/data/remote-data'; import { getSucceededRemoteData } from '../core/shared/operators'; import { ItemDataService } from '../core/data/item-data.service'; 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 diff --git a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts new file mode 100644 index 0000000000..9461ee0950 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts @@ -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; + +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); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts index a8cc309ab6..00984d6592 100644 --- a/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ 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; + /** + * Separator string between multiple values of the metadata fields defined + * @type {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[] = [ 'dc.description.abstract' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.abstract'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts new file mode 100644 index 0000000000..d865caff8a --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts @@ -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; + +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); + }); + }); + } +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts index e84a52d1b9..51941d2cc8 100644 --- a/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/author/item-page-author-field.component.ts @@ -1,24 +1,41 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ 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; + /** + * Separator string between multiple values of the metadata fields defined + * @type {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[] = [ 'dc.contributor.author', 'dc.creator', 'dc.contributor' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.author'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts new file mode 100644 index 0000000000..2adada582b --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts @@ -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; + +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); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts index 6950944f87..5a7d56b7da 100644 --- a/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/date/item-page-date-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ 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; + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ 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[] = [ 'dc.date.issued' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.date'; } diff --git a/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts new file mode 100644 index 0000000000..d8abd39cf3 --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts @@ -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; + +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); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts new file mode 100644 index 0000000000..ee7d27a11f --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.ts @@ -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; + +} diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html similarity index 76% rename from src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html rename to src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html index d6a569198c..fd3055d197 100644 --- a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.html +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.html @@ -1,3 +1,3 @@ -
+
diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts new file mode 100644 index 0000000000..a0d24387cf --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.spec.ts @@ -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; + +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 + }] + }); +} diff --git a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts similarity index 82% rename from src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts rename to src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts index f69671a5b5..ce2b110efd 100644 --- a/src/app/+item-page/simple/field-components/specific-field/item-page-specific-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/item-page-field.component.ts @@ -9,10 +9,13 @@ import { Item } from '../../../../core/shared/item.model'; */ @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; /** diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts new file mode 100644 index 0000000000..cb1ba6a4bc --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts @@ -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; + +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); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts index be8102359a..c67d8bcf62 100644 --- a/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/title/item-page-title-field.component.ts @@ -1,18 +1,32 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-title-field', 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; + /** + * Separator string between multiple values of the metadata fields defined + * @type {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[] = [ 'dc.title' ]; diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html index a5561b22e5..2b19754127 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html @@ -1,3 +1,3 @@ -
+
diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts new file mode 100644 index 0000000000..4511f16aae --- /dev/null +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -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; + +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); + }); +}); diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts index 4f06337032..c9cd5f1a00 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts @@ -1,22 +1,39 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; -import { ItemPageSpecificFieldComponent } from '../item-page-specific-field.component'; +import { ItemPageFieldComponent } from '../item-page-field.component'; @Component({ selector: 'ds-item-page-uri-field', 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; + /** + * Separator string between multiple values of the metadata fields defined + * @type {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[] = [ 'dc.identifier.uri' ]; + /** + * Label i18n key for the rendered metadata + */ label = 'item.page.uri'; } diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html index 98b98a5e32..b6de496dc4 100644 --- a/src/app/+item-page/simple/item-page.component.html +++ b/src/app/+item-page/simple/item-page.component.html @@ -1,27 +1,7 @@
- -
-
- - - - - - -
- -
+
diff --git a/src/app/+item-page/simple/item-page.component.scss b/src/app/+item-page/simple/item-page.component.scss index 50be6f5ad0..4c26cf08fb 100644 --- a/src/app/+item-page/simple/item-page.component.scss +++ b/src/app/+item-page/simple/item-page.component.scss @@ -1 +1,9 @@ @import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +@include media-breakpoint-down(md) { + .container { + width: 100%; + max-width: none; + } +} diff --git a/src/app/+item-page/simple/item-page.component.spec.ts b/src/app/+item-page/simple/item-page.component.spec.ts new file mode 100644 index 0000000000..fbe616227f --- /dev/null +++ b/src/app/+item-page/simple/item-page.component.spec.ts @@ -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; + + 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(); + }); + }); + +}); diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index 35162b011f..ac23add738 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -1,5 +1,4 @@ - -import {mergeMap, filter, map} from 'rxjs/operators'; +import { filter, map, mergeMap } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; @@ -14,6 +13,9 @@ import { MetadataService } from '../../core/metadata/metadata.service'; import { fadeInOut } from '../../shared/animations/fade'; 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. @@ -29,21 +31,31 @@ import { hasValue } from '../../shared/empty.util'; }) export class ItemPageComponent implements OnInit { + /** + * The item's id + */ id: number; - private sub: any; - + /** + * The item wrapped in a remote-data object + */ itemRD$: Observable>; + /** + * The item's thumbnail + */ thumbnail$: Observable; + /** + * The view-mode we're currently on + */ + viewMode = VIEW_MODE_FULL; + constructor( private route: ActivatedRoute, private items: ItemDataService, - private metadataService: MetadataService - ) { - - } + private metadataService: MetadataService, + ) { } ngOnInit(): void { this.itemRD$ = this.route.data.pipe(map((data) => data.item)); diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html new file mode 100644 index 0000000000..e17a902297 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.html @@ -0,0 +1,50 @@ +

+ +

+
+
+ + + + + + + + + + + +
+ +
diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts new file mode 100644 index 0000000000..a648250b03 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.spec.ts @@ -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)); diff --git a/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts new file mode 100644 index 0000000000..9240616c59 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-issue/journal-issue.component.ts @@ -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; + + /** + * The publications related to this journal issue + */ + publications$: Observable; + + 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) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html new file mode 100644 index 0000000000..a956e40fdd --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.html @@ -0,0 +1,37 @@ +

+ +

+
+
+ + + + + + + +
+ +
diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts new file mode 100644 index 0000000000..5442048a50 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.spec.ts @@ -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)); diff --git a/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts new file mode 100644 index 0000000000..0372fe5d30 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal-volume/journal-volume.component.ts @@ -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; + + /** + * The journal issues related to this journal volume + */ + issues$: Observable; + + 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) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.html b/src/app/+item-page/simple/item-types/journal/journal.component.html new file mode 100644 index 0000000000..157d6133bf --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.html @@ -0,0 +1,42 @@ +

+ +

+
+
+ + + + + + + + + +
+ +
+ + +
+
diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.scss b/src/app/+item-page/simple/item-types/journal/journal.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts b/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts new file mode 100644 index 0000000000..8a18f0f727 --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.spec.ts @@ -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; + +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; +} diff --git a/src/app/+item-page/simple/item-types/journal/journal.component.ts b/src/app/+item-page/simple/item-types/journal/journal.component.ts new file mode 100644 index 0000000000..12ba91eb6b --- /dev/null +++ b/src/app/+item-page/simple/item-types/journal/journal.component.ts @@ -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; + + 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) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html new file mode 100644 index 0000000000..7eacf66347 --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.html @@ -0,0 +1,49 @@ +

+ +

+
+
+ + + + + + + + + + + +
+ +
diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts new file mode 100644 index 0000000000..bb356ba7fb --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.spec.ts @@ -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)); diff --git a/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts new file mode 100644 index 0000000000..1a6ab4ba36 --- /dev/null +++ b/src/app/+item-page/simple/item-types/orgunit/orgunit.component.ts @@ -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; + + /** + * The projects related to this organisation unit + */ + projects$: Observable; + + /** + * The publications related to this organisation unit + */ + publications$: Observable; + + 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) + ); + } + }} diff --git a/src/app/+item-page/simple/item-types/person/person.component.html b/src/app/+item-page/simple/item-types/person/person.component.html new file mode 100644 index 0000000000..30153353de --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.html @@ -0,0 +1,58 @@ +

+ +

+
+
+ + + + + + + + + + + +
+ +
+ + +
+
diff --git a/src/app/+item-page/simple/item-types/person/person.component.scss b/src/app/+item-page/simple/item-types/person/person.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/person/person.component.spec.ts b/src/app/+item-page/simple/item-types/person/person.component.spec.ts new file mode 100644 index 0000000000..4c582f67e8 --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.spec.ts @@ -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)); diff --git a/src/app/+item-page/simple/item-types/person/person.component.ts b/src/app/+item-page/simple/item-types/person/person.component.ts new file mode 100644 index 0000000000..3cf5a230bf --- /dev/null +++ b/src/app/+item-page/simple/item-types/person/person.component.ts @@ -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; + + /** + * The projects related to this person + */ + projects$: Observable; + + /** + * The organisation units related to this person + */ + orgUnits$: Observable; + + /** + * The applied fixed filter + */ + fixedFilter$: Observable; + + /** + * 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'); + } + } +} diff --git a/src/app/+item-page/simple/item-types/project/project.component.html b/src/app/+item-page/simple/item-types/project/project.component.html new file mode 100644 index 0000000000..17aa8b2065 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.html @@ -0,0 +1,49 @@ +

+ +

+
+
+ + + + + + + + + +
+ +
diff --git a/src/app/+item-page/simple/item-types/project/project.component.scss b/src/app/+item-page/simple/item-types/project/project.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/project/project.component.spec.ts b/src/app/+item-page/simple/item-types/project/project.component.spec.ts new file mode 100644 index 0000000000..e28c97f87d --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.spec.ts @@ -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)); diff --git a/src/app/+item-page/simple/item-types/project/project.component.ts b/src/app/+item-page/simple/item-types/project/project.component.ts new file mode 100644 index 0000000000..4bdb6012f2 --- /dev/null +++ b/src/app/+item-page/simple/item-types/project/project.component.ts @@ -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; + + /** + * The publications related to this project + */ + publications$: Observable; + + /** + * The organisation units related to this project + */ + orgUnits$: Observable; + + 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) + ); + } + } +} diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.html b/src/app/+item-page/simple/item-types/publication/publication.component.html new file mode 100644 index 0000000000..ab1ce097f5 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.html @@ -0,0 +1,58 @@ + +
+
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + +
+
diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.scss b/src/app/+item-page/simple/item-types/publication/publication.component.scss new file mode 100644 index 0000000000..3575cae797 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.scss @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts new file mode 100644 index 0000000000..603d358761 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts @@ -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; + + 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); + }); + +}); diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.ts b/src/app/+item-page/simple/item-types/publication/publication.component.ts new file mode 100644 index 0000000000..1ea50598c7 --- /dev/null +++ b/src/app/+item-page/simple/item-types/publication/publication.component.ts @@ -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; + + /** + * The projects related to this publication + */ + projects$: Observable; + + /** + * The organisation units related to this publication + */ + orgUnits$: Observable; + + /** + * The journal issues related to this publication + */ + journalIssues$: Observable; + + 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) + ); + + } + } +} diff --git a/src/app/+item-page/simple/item-types/shared/item.component.spec.ts b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts new file mode 100644 index 0000000000..28bd8e33e3 --- /dev/null +++ b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts @@ -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; + + 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((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((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((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((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; + + 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; + + 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); + }); + }); + }) + +}); diff --git a/src/app/+item-page/simple/item-types/shared/item.component.ts b/src/app/+item-page/simple/item-types/shared/item.component.ts new file mode 100644 index 0000000000..34bf213b84 --- /dev/null +++ b/src/app/+item-page/simple/item-types/shared/item.component.ts @@ -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 = (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 = () => + 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} + */ +export const filterRelationsByTypeLabel = (label: string) => + (source: Observable<[Relationship[], RelationshipType[]]>): Observable => + 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) => Observable} + */ +export const relationsToItems = (thisId: string, ids: ItemDataService) => + (source: Observable): Observable => + 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>) => + arr + .filter((d: RemoteData) => d.hasSucceeded) + .map((d: RemoteData) => 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): Observable => + 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) => 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>) => rd.hasSucceeded), + getRemoteDataPayload(), + map((pl: PaginatedList) => pl.page), + distinctUntilChanged(compareArraysUsingIds()) + ); + + const relTypesCurrentPage$ = relsCurrentPage$.pipe( + flatMap((rels: Relationship[]) => + observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe( + map(([...arr]: Array>) => arr.map((d: RemoteData) => 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 { + const metadata = this.item.findMetadataSortedByPlace(metadataField); + const relsCurrentPage$ = this.item.relationships.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((pl: PaginatedList) => pl.page), + distinctUntilChanged(compareArraysUsingIds()) + ); + + return relsCurrentPage$.pipe( + relationsToRepresentations(this.item.id, itemType, metadata, itemDataService) + ); + } + +} diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html new file mode 100644 index 0000000000..48eabf8451 --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html @@ -0,0 +1,5 @@ + + + + diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts new file mode 100644 index 0000000000..77ba30778e --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts @@ -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; + + 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); + }); + +}); diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts new file mode 100644 index 0000000000..b821557f4a --- /dev/null +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -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; +} diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html new file mode 100644 index 0000000000..9ec082db73 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts new file mode 100644 index 0000000000..e76a9cf3d0 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts @@ -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; + 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); + }) + }); + +}); diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts new file mode 100644 index 0000000000..672655a8b8 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts @@ -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; + + 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); + } + } + +} diff --git a/src/app/+item-page/simple/related-items/related-items-component.ts b/src/app/+item-page/simple/related-items/related-items-component.ts new file mode 100644 index 0000000000..ce8ca58b29 --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items-component.ts @@ -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; +} diff --git a/src/app/+item-page/simple/related-items/related-items.component.html b/src/app/+item-page/simple/related-items/related-items.component.html new file mode 100644 index 0000000000..4b284ad63c --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items.component.html @@ -0,0 +1,5 @@ + + + + diff --git a/src/app/+item-page/simple/related-items/related-items.component.scss b/src/app/+item-page/simple/related-items/related-items.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+item-page/simple/related-items/related-items.component.spec.ts b/src/app/+item-page/simple/related-items/related-items.component.spec.ts new file mode 100644 index 0000000000..ef42ab1098 --- /dev/null +++ b/src/app/+item-page/simple/related-items/related-items.component.spec.ts @@ -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; + + 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); + }); + +}); diff --git a/src/app/+search-page/filtered-search-page.component.spec.ts b/src/app/+search-page/filtered-search-page.component.spec.ts new file mode 100644 index 0000000000..5c49767ed2 --- /dev/null +++ b/src/app/+search-page/filtered-search-page.component.spec.ts @@ -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; + 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); + }); + }); + +}); diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts new file mode 100644 index 0000000000..21e8b8c1c1 --- /dev/null +++ b/src/app/+search-page/filtered-search-page.component.ts @@ -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} + */ + protected getSearchOptions(): Observable { + this.searchConfigService.updateFixedFilter(this.fixedFilterQuery); + return this.searchConfigService.paginatedSearchOptions; + } + +} diff --git a/src/app/+search-page/filtered-search-page.guard.ts b/src/app/+search-page/filtered-search-page.guard.ts new file mode 100644 index 0000000000..548e4d8dce --- /dev/null +++ b/src/app/+search-page/filtered-search-page.guard.ts @@ -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 | Promise | boolean { + const filter = route.params.filter; + + const newTitle = route.data.title + filter + '.title'; + + route.data = { title: newTitle }; + return true; + } +} diff --git a/src/app/+search-page/paginated-search-options.model.ts b/src/app/+search-page/paginated-search-options.model.ts index 8f4d93b0df..387c116a56 100644 --- a/src/app/+search-page/paginated-search-options.model.ts +++ b/src/app/+search-page/paginated-search-options.model.ts @@ -12,7 +12,7 @@ export class PaginatedSearchOptions extends SearchOptions { pagination?: PaginationComponentOptions; 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); this.pagination = options.pagination; this.sort = options.sort; diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts index 156e8d47ea..92739dbf2c 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts @@ -13,8 +13,10 @@ import { import { SearchFiltersState } from './search-filter.reducer'; import { SearchFilterConfig } from '../../search-service/search-filter-config.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 { of as observableOf } from 'rxjs'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; describe('SearchFilterService', () => { let service: SearchFilterService; @@ -26,6 +28,12 @@ describe('SearchFilterService', () => { isOpenByDefault: false, pageSize: 2 }); + + const mockFixedFilterService: SearchFixedFilterService = { + getQueryByFilterName: (filter: string) => { + return observableOf(undefined) + } + } as SearchFixedFilterService const value1 = 'random value'; // const value2 = 'another value'; const store: Store = jasmine.createSpyObj('store', { @@ -45,11 +53,15 @@ describe('SearchFilterService', () => { }, addQueryParameterValue: (param: string, value: string) => { }, + getQueryParameterValue: (param: string) => { + }, getQueryParameterValues: (param: string) => { return observableOf({}); }, getQueryParamsWithPrefix: (param: string) => { return observableOf({}); + }, + getRouteParameterValue: (param: string) => { } /* tslint:enable:no-empty */ }; @@ -59,7 +71,7 @@ describe('SearchFilterService', () => { }; beforeEach(() => { - service = new SearchFilterService(store, routeServiceStub); + service = new SearchFilterService(store, routeServiceStub, mockFixedFilterService); }); 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(); + }); + }); + }); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts index bf21eab367..e0c189e26f 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts @@ -1,6 +1,6 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { mergeMap, map, distinctUntilChanged } from 'rxjs/operators'; import { Injectable, InjectionToken } from '@angular/core'; -import { map } from 'rxjs/operators'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { @@ -16,6 +16,11 @@ import { import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; 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'; const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; @@ -29,8 +34,8 @@ export const FILTER_CONFIG: InjectionToken = new InjectionTo export class SearchFilterService { constructor(private store: Store, - private routeService: RouteService - ) { + private routeService: RouteService, + private fixedFilterService: SearchFixedFilterService) { } /** @@ -52,6 +57,138 @@ export class SearchFilterService { return this.routeService.hasQueryParam(paramName); } + /** + * Fetch the current active scope from the query parameters + * @returns {Observable} + */ + getCurrentScope() { + return this.routeService.getQueryParameterValue('scope'); + } + + /** + * Fetch the current query from the query parameters + * @returns {Observable} + */ + 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} + */ + getCurrentPagination(pagination: any = {}): Observable { + 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} + */ + getCurrentSort(defaultSort: SortOptions): Observable { + 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} + */ + 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} + */ + getCurrentFixedFilter(): Observable { + const filter: Observable = this.routeService.getRouteParameterValue('filter'); + return filter.pipe(mergeMap((f) => this.fixedFilterService.getQueryByFilterName(f))); + } + + /** + * Fetch the current view from the query parameters + * @returns {Observable} + */ + 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} + */ + getPaginatedSearchOptions(defaults: any = {}): Observable { + 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} + */ + getSearchOptions(defaults: any = {}): Observable { + 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 * @param {SearchFilterConfig} filterConfig The configuration for which the filters are active diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts new file mode 100644 index 0000000000..06e12d444e --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts @@ -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); + }); + }); + +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts new file mode 100644 index 0000000000..24688b798c --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts @@ -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} Filter query + */ + getQueryByFilterName(filterName: string): Observable { + 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 { + return FilteredDiscoveryPageResponseParsingService; + } + }); + }), + configureRequest(this.requestService) + ); + + // get search results from response cache + const filterQuery: Observable = 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}`; + } + +} diff --git a/src/app/+search-page/search-options.model.ts b/src/app/+search-page/search-options.model.ts index 123cf950f8..69309bed66 100644 --- a/src/app/+search-page/search-options.model.ts +++ b/src/app/+search-page/search-options.model.ts @@ -3,21 +3,25 @@ import { URLCombiner } from '../core/url-combiner/url-combiner'; import 'core-js/library/fn/object/entries'; import { SearchFilter } from './search-filter.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 */ export class SearchOptions { + view?: SetViewMode = SetViewMode.List; scope?: string; query?: string; 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.query = options.query; this.dsoType = options.dsoType; 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 */ toRestUrl(url: string, args: string[] = []): string { - + if (isNotEmpty(this.fixedFilter)) { + args.push(this.fixedFilter); + } if (isNotEmpty(this.query)) { args.push(`query=${this.query}`); } diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index 65cca99a34..8c138c0d52 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -2,11 +2,14 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { SearchPageComponent } from './search-page.component'; +import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { FilteredSearchPageGuard } from './filtered-search-page.guard'; @NgModule({ imports: [ 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.' }} ]) ] }) diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 6476f8bd68..adab27d8e9 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -1,16 +1,16 @@
- -
- + - +
+ [searchConfig]="searchOptions$ | async" + [fixedFilter]="fixedFilter$ | async" + [disableHeader]="!searchEnabled">
diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts index 1991cf8f1b..68a0dc6352 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search-page.component.spec.ts @@ -20,91 +20,114 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { RemoteData } from '../core/data/remote-data'; +import { RouteService } from '../shared/services/route.service'; + +let comp: SearchPageComponent; +let fixture: ComponentFixture; +let searchServiceObject: SearchService; +const store: Store = 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', () => { - let comp: SearchPageComponent; - let fixture: ComponentFixture; - let searchServiceObject: SearchService; - const store: Store = 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(() => { - TestBed.configureTestingModule({ - 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(); + configureSearchComponentTestingModule(SearchPageComponent); })); beforeEach(() => { @@ -177,4 +200,4 @@ describe('SearchPageComponent', () => { }); }); -}) +}); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 816e3d67bf..8db94bc8d2 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable , Subscription , BehaviorSubject } from 'rxjs'; import { switchMap, } from 'rxjs/operators'; import { PaginatedList } from '../core/data/paginated-list'; 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 { SearchService } from './search-service/search.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 { getSucceededRemoteData } from '../core/shared/operators'; - -/** - * 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. - */ +import { RouteService } from '../shared/services/route.service'; @Component({ selector: 'ds-search-page', @@ -31,6 +26,7 @@ import { getSucceededRemoteData } from '../core/shared/operators'; /** * This component represents the whole search page + * It renders search results depending on the current search options */ export class SearchPageComponent implements OnInit { @@ -59,11 +55,30 @@ export class SearchPageComponent implements OnInit { */ sub: Subscription; - constructor(private service: SearchService, - private sidebarService: SearchSidebarService, - private windowService: HostWindowService, - private filterService: SearchFilterService, - private searchConfigService: SearchConfigurationService) { + /** + * Whether or not the search bar should be visible + */ + @Input() + searchEnabled = true; + + /** + * The width of the sidebar (bootstrap columns) + */ + @Input() + sideBarWidth = 3; + + /** + * The currently applied filter (determines title of search) + */ + @Input() + fixedFilter$: Observable; + + constructor(protected service: SearchService, + protected sidebarService: SearchSidebarService, + protected windowService: HostWindowService, + protected filterService: SearchFilterService, + protected searchConfigService: SearchConfigurationService, + protected routeService: RouteService) { 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 */ ngOnInit(): void { - this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; + this.searchOptions$ = this.getSearchOptions(); this.sub = this.searchOptions$.pipe( switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))) .subscribe((results) => { @@ -84,6 +99,17 @@ export class SearchPageComponent implements OnInit { this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( switchMap((scopeId) => this.service.getScopes(scopeId)) ); + if (!isNotEmpty(this.fixedFilter$)) { + this.fixedFilter$ = this.routeService.getRouteParameterValue('filter'); + } + } + + /** + * Get the current paginated search options + * @returns {Observable} + */ + protected getSearchOptions(): Observable { + return this.searchConfigService.paginatedSearchOptions; } /** diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index 0c8a4ee306..b32e504966 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -21,6 +21,9 @@ import { SearchFiltersComponent } from './search-filters/search-filters.componen 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 { 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 { 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'; @@ -43,6 +46,7 @@ const effects = [ ], declarations: [ SearchPageComponent, + FilteredSearchPageComponent, SearchResultsComponent, SearchSidebarComponent, SearchSettingsComponent, @@ -68,6 +72,9 @@ const effects = [ SearchService, SearchSidebarService, SearchFilterService, + SearchFixedFilterService, + FilteredSearchPageGuard, + SearchFilterService, SearchConfigurationService ], entryComponents: [ @@ -82,6 +89,9 @@ const effects = [ SearchTextFilterComponent, SearchHierarchyFilterComponent, SearchBooleanFilterComponent, + ], + exports: [ + FilteredSearchPageComponent, ] }) diff --git a/src/app/+search-page/search-results/search-results.component.html b/src/app/+search-page/search-results/search-results.component.html index 4915b552c3..d7ecb0357e 100644 --- a/src/app/+search-page/search-results/search-results.component.html +++ b/src/app/+search-page/search-results/search-results.component.html @@ -1,4 +1,4 @@ -

{{ 'search.results.head' | translate }}

+

{{ getTitleKey() | translate }}

{ let comp: SearchResultsComponent; let fixture: ComponentFixture; + let heading: DebugElement; + let title: DebugElement; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -24,7 +26,9 @@ describe('SearchResultsComponent', () => { beforeEach(() => { 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', () => { diff --git a/src/app/+search-page/search-results/search-results.component.ts b/src/app/+search-page/search-results/search-results.component.ts index ae0abfcd27..8236eddd50 100644 --- a/src/app/+search-page/search-results/search-results.component.ts +++ b/src/app/+search-page/search-results/search-results.component.ts @@ -2,11 +2,12 @@ import { Component, Input } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { fadeIn, fadeInOut } from '../../shared/animations/fade'; +import { SetViewMode } from '../../shared/view-mode'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; import { PaginatedList } from '../../core/data/paginated-list'; -import { ViewMode } from '../../core/shared/view-mode.model'; import { isNotEmpty } from '../../shared/empty.util'; +import { SortOptions } from '../../core/cache/models/sort-options.model'; @Component({ selector: 'ds-search-results', @@ -30,11 +31,23 @@ export class SearchResultsComponent { * The current configuration of the search */ @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. diff --git a/src/app/+search-page/search-service/search-configuration.service.spec.ts b/src/app/+search-page/search-service/search-configuration.service.spec.ts index af8897c93b..68405dcf6b 100644 --- a/src/app/+search-page/search-service/search-configuration.service.spec.ts +++ b/src/app/+search-page/search-service/search-configuration.service.spec.ts @@ -23,17 +23,21 @@ describe('SearchConfigurationService', () => { 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), - getQueryParamsWithPrefix: observableOf(prefixFilter) + getQueryParamsWithPrefix: observableOf(prefixFilter), + getRouteParameterValue: observableOf('') + }); + + const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', { + getQueryByFilterName: observableOf(''), }); const activatedRoute: any = new ActivatedRouteStub(); beforeEach(() => { - service = new SearchConfigurationService(spy, activatedRoute); + service = new SearchConfigurationService(routeService, fixedFilterService, activatedRoute); }); - describe('when the scope is called', () => { beforeEach(() => { 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); + }); + }); }); diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts index 292f26724d..d642944d18 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -6,7 +6,7 @@ import { of as observableOf, Subscription } 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 { PaginationComponentOptions } from '../../shared/pagination/pagination-component-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 { Injectable, OnDestroy } from '@angular/core'; 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 { getSucceededRemoteData } from '../../core/shared/operators'; import { SearchFilter } from '../search-filter.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 @@ -75,6 +76,7 @@ export class SearchConfigurationService implements OnDestroy { * @param {ActivatedRoute} route */ constructor(private routeService: RouteService, + private fixedFilterService: SearchFixedFilterService, private route: ActivatedRoute) { this.defaults .pipe(getSucceededRemoteData()) @@ -174,6 +176,15 @@ export class SearchConfigurationService implements OnDestroy { })); } + /** + * @returns {Observable} Emits the current fixed filter as a string + */ + getCurrentFixedFilter(): Observable { + return this.routeService.getRouteParameterValue('filter').pipe( + flatMap((f) => this.fixedFilterService.getQueryByFilterName(f)) + ); + } + /** * @returns {Observable} 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.getQueryPart(defaults.query), this.getDSOTypePart(), - this.getFiltersPart() + this.getFiltersPart(), + this.getFixedFilterPart() ).subscribe((update) => { const currentValue: SearchOptions = this.searchOptions.getValue(); const updatedValue: SearchOptions = Object.assign(currentValue, update); @@ -211,7 +223,8 @@ export class SearchConfigurationService implements OnDestroy { this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), this.getDSOTypePart(), - this.getFiltersPart() + this.getFiltersPart(), + this.getFixedFilterPart() ).subscribe((update) => { const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update); @@ -297,4 +310,30 @@ export class SearchConfigurationService implements OnDestroy { return { filters } })); } + + /** + * @returns {Observable} Emits the current fixed filter as a partial SearchOptions object + */ + private getFixedFilterPart(): Observable { + 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); + } } diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/+search-page/search-service/search.service.spec.ts index 4af0ffcb2e..20364b18ac 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -5,6 +5,9 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; 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 { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { RequestService } from '../../core/data/request.service'; @@ -64,7 +67,7 @@ describe('SearchService', () => { it('should return list view mode', () => { 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', () => { - searchService.setViewMode(ViewMode.List); + searchService.setViewMode(SetViewMode.List); expect(router.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { view: ViewMode.List }, + queryParams: { view: SetViewMode.List }, queryParamsHandling: 'merge' }); }); 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'], { - queryParams: { view: ViewMode.Grid }, + queryParams: { view: SetViewMode.Grid }, queryParamsHandling: 'merge' }); }); it('should return ViewMode.List when the viewMode is set to ViewMode.List in the ActivatedRoute', () => { - let viewMode = ViewMode.Grid; - route.testParams = { view: ViewMode.List }; + let viewMode = SetViewMode.Grid; + route.testParams = { view: SetViewMode.List }; 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', () => { - let viewMode = ViewMode.List; - route.testParams = { view: ViewMode.Grid }; + let viewMode = SetViewMode.List; + route.testParams = { view: SetViewMode.Grid }; searchService.getViewMode().subscribe((mode) => viewMode = mode); - expect(viewMode).toEqual(ViewMode.Grid); + expect(viewMode).toEqual(SetViewMode.Grid); }); describe('when search is called', () => { diff --git a/src/app/+search-page/search-settings/search-settings.component.ts b/src/app/+search-page/search-settings/search-settings.component.ts index 7fc5645fcc..f25545eddf 100644 --- a/src/app/+search-page/search-settings/search-settings.component.ts +++ b/src/app/+search-page/search-settings/search-settings.component.ts @@ -53,7 +53,7 @@ export class SearchSettingsComponent implements OnInit { }, queryParamsHandling: 'merge' }; - this.router.navigate([ '/search' ], navigationExtras); + this.router.navigate([ this.service.getSearchLink() ], navigationExtras); } /** @@ -70,6 +70,6 @@ export class SearchSettingsComponent implements OnInit { }, queryParamsHandling: 'merge' }; - this.router.navigate([ '/search' ], navigationExtras); + this.router.navigate([ this.service.getSearchLink() ], navigationExtras); } } diff --git a/src/app/core/cache/models/items/normalized-item-type.model.ts b/src/app/core/cache/models/items/normalized-item-type.model.ts new file mode 100644 index 0000000000..ed38d80a4b --- /dev/null +++ b/src/app/core/cache/models/items/normalized-item-type.model.ts @@ -0,0 +1,32 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { ItemType } from '../../../shared/item-relationships/item-type.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo } from '../../builders/build-decorators'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace ItemType + */ +@mapsTo(ItemType) +@inheritSerialization(NormalizedObject) +export class NormalizedItemType extends NormalizedObject { + + /** + * The label that describes the ResourceType of the Item + */ + @autoserialize + label: string; + + /** + * The identifier of this ItemType + */ + @autoserialize + id: string; + + /** + * The universally unique identifier of this ItemType + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.ItemType), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/items/normalized-relationship-type.model.ts b/src/app/core/cache/models/items/normalized-relationship-type.model.ts new file mode 100644 index 0000000000..d201fb2746 --- /dev/null +++ b/src/app/core/cache/models/items/normalized-relationship-type.model.ts @@ -0,0 +1,77 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { RelationshipType } from '../../../shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo, relationship } from '../../builders/build-decorators'; +import { NormalizedDSpaceObject } from '../normalized-dspace-object.model'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace RelationshipType + */ +@mapsTo(RelationshipType) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedRelationshipType extends NormalizedObject { + + /** + * The identifier of this RelationshipType + */ + @autoserialize + id: string; + + /** + * The label that describes the Relation to the left of this RelationshipType + */ + @autoserialize + leftLabel: string; + + /** + * The maximum amount of Relationships allowed to the left of this RelationshipType + */ + @autoserialize + leftMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the left of this RelationshipType + */ + @autoserialize + leftMinCardinality: number; + + /** + * The label that describes the Relation to the right of this RelationshipType + */ + @autoserialize + rightLabel: string; + + /** + * The maximum amount of Relationships allowed to the right of this RelationshipType + */ + @autoserialize + rightMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the right of this RelationshipType + */ + @autoserialize + rightMinCardinality: number; + + /** + * The type of Item found to the left of this RelationshipType + */ + @autoserialize + @relationship(ResourceType.ItemType, false) + leftType: string; + + /** + * The type of Item found to the right of this RelationshipType + */ + @autoserialize + @relationship(ResourceType.ItemType, false) + rightType: string; + + /** + * The universally unique identifier of this RelationshipType + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.RelationshipType), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/items/normalized-relationship.model.ts b/src/app/core/cache/models/items/normalized-relationship.model.ts new file mode 100644 index 0000000000..b908426361 --- /dev/null +++ b/src/app/core/cache/models/items/normalized-relationship.model.ts @@ -0,0 +1,57 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { Relationship } from '../../../shared/item-relationships/relationship.model'; +import { ResourceType } from '../../../shared/resource-type'; +import { mapsTo, relationship } from '../../builders/build-decorators'; +import { NormalizedObject } from '../normalized-object.model'; +import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; + +/** + * Normalized model class for a DSpace Relationship + */ +@mapsTo(Relationship) +@inheritSerialization(NormalizedObject) +export class NormalizedRelationship extends NormalizedObject { + + /** + * The identifier of this Relationship + */ + @autoserialize + id: string; + + /** + * The identifier of the Item to the left side of this Relationship + */ + @autoserialize + leftId: string; + + /** + * The identifier of the Item to the right side of this Relationship + */ + @autoserialize + rightId: string; + + /** + * The place of the Item to the left side of this Relationship + */ + @autoserialize + leftPlace: number; + + /** + * The place of the Item to the right side of this Relationship + */ + @autoserialize + rightPlace: number; + + /** + * The type of Relationship + */ + @autoserialize + @relationship(ResourceType.RelationshipType, false) + relationshipType: string; + + /** + * The universally unique identifier of this Relationship + */ + @autoserializeAs(new IDToUUIDSerializer(ResourceType.Relationship), 'id') + uuid: string; +} diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts index 9e8c034e81..d2b7b9c92d 100644 --- a/src/app/core/cache/models/normalized-item.model.ts +++ b/src/app/core/cache/models/normalized-item.model.ts @@ -63,4 +63,8 @@ export class NormalizedItem extends NormalizedDSpaceObject { @relationship(ResourceType.Bitstream, true) bitstreams: string[]; + @autoserialize + @relationship(ResourceType.Relationship, true) + relationships: string[]; + } diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index 681dbea984..c7f6bcc36a 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -1,3 +1,6 @@ +import { NormalizedItemType } from './items/normalized-item-type.model'; +import { NormalizedRelationshipType } from './items/normalized-relationship-type.model'; +import { NormalizedRelationship } from './items/normalized-relationship.model'; import { NormalizedBitstream } from './normalized-bitstream.model'; import { NormalizedBundle } from './normalized-bundle.model'; import { NormalizedItem } from './normalized-item.model'; @@ -37,6 +40,15 @@ export class NormalizedObjectFactory { case ResourceType.ResourcePolicy: { return NormalizedResourcePolicy } + case ResourceType.Relationship: { + return NormalizedRelationship + } + case ResourceType.RelationshipType: { + return NormalizedRelationshipType + } + case ResourceType.ItemType: { + return NormalizedItemType + } case ResourceType.EPerson: { return NormalizedEPerson } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index bb150b3bcb..f9bdfb8a39 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -201,4 +201,13 @@ export class IntegrationSuccessResponse extends RestResponse { } } +export class FilteredDiscoveryQueryResponse extends RestResponse { + constructor( + public filterQuery: string, + public statusCode: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode); + } +} /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 357f552074..c1a248455c 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -63,6 +63,7 @@ import { UploaderService } from '../shared/uploader/uploader.service'; import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service'; import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; +import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { MenuService } from '../shared/menu/menu.service'; import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; @@ -145,6 +146,7 @@ const PROVIDERS = [ multi: true }, NotificationsService, + FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory } ]; diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 925caa495c..f7f904f790 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -68,7 +68,9 @@ export abstract class BaseResponseParsingService { let list = data._embedded; // Workaround for inconsistency in rest response. Issue: https://github.com/DSpace/dspace-angular/issues/238 - if (!Array.isArray(list)) { + if (hasNoValue(list)) { + list = []; + } else if (!Array.isArray(list)) { list = this.flattenSingleKeyObject(list); } const page: ObjectDomain[] = this.processArray(list, requestUUID); diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts new file mode 100644 index 0000000000..13eaeb03d4 --- /dev/null +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts @@ -0,0 +1,35 @@ +import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; +import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { ResponseParsingService } from './parsing.service'; +import { GetRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { FilteredDiscoveryQueryResponse } from '../cache/response-cache.models'; + +describe('FilteredDiscoveryPageResponseParsingService', () => { + let service: FilteredDiscoveryPageResponseParsingService; + + beforeEach(() => { + service = new FilteredDiscoveryPageResponseParsingService(undefined, getMockObjectCacheService()); + }); + + describe('parse', () => { + const request = Object.assign(new GetRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/path'), { + getResponseParser(): GenericConstructor { + return FilteredDiscoveryPageResponseParsingService; + } + }); + + const mockResponse = { + payload: { + 'discovery-query': 'query' + }, + statusCode: '200' + } as DSpaceRESTV2Response; + + it('should return a FilteredDiscoveryQueryResponse containing the correct query', () => { + const response = service.parse(request, mockResponse); + expect((response as FilteredDiscoveryQueryResponse).filterQuery).toBe(mockResponse.payload['discovery-query']); + }) + }); +}); diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts new file mode 100644 index 0000000000..45f7ae3069 --- /dev/null +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable } from '@angular/core'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../../config'; +import { FilteredDiscoveryQueryResponse, RestResponse } from '../cache/response.models'; + +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a discovery query (string) + * wrapped in a FilteredDiscoveryQueryResponse + */ +@Injectable() +export class FilteredDiscoveryPageResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + objectFactory = {}; + toCache = false; + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { super(); + } + + /** + * Parses data from the REST API to a discovery query wrapped in a FilteredDiscoveryQueryResponse + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const query = data.payload['discovery-query']; + return new FilteredDiscoveryQueryResponse(query, data.statusCode); + } +} diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 736bf11923..c3782f56dc 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -59,7 +59,7 @@ export class MetadataService { map((route: ActivatedRoute) => { route = this.getCurrentRoute(route); return { params: route.params, data: route.data }; - }),).subscribe((routeInfo: any) => { + })).subscribe((routeInfo: any) => { this.processRouteChange(routeInfo); }); } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index a2f5f721a4..085988d745 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -10,6 +10,7 @@ import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { Observable } from 'rxjs'; +import { hasNoValue } from '../../shared/empty.util'; /** * An abstract model class for a DSpaceObject. @@ -117,4 +118,23 @@ export class DSpaceObject implements CacheableObject, ListableObject { return Metadata.has(this.metadata, keyOrKeys, valueFilter); } + /** + * Find metadata on a specific field and order all of them using their "place" property. + * @param key + */ + findMetadataSortedByPlace(key: string): MetadataValue[] { + return this.allMetadata([key]).sort((a: MetadataValue, b: MetadataValue) => { + if (hasNoValue(a.place) && hasNoValue(b.place)) { + return 0; + } + if (hasNoValue(a.place)) { + return -1; + } + if (hasNoValue(b.place)) { + return 1; + } + return a.place - b.place; + }); + } + } diff --git a/src/app/core/shared/item-relationships/item-type.model.ts b/src/app/core/shared/item-relationships/item-type.model.ts new file mode 100644 index 0000000000..e4f98ab653 --- /dev/null +++ b/src/app/core/shared/item-relationships/item-type.model.ts @@ -0,0 +1,27 @@ +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../resource-type'; + +/** + * Describes a type of Item + */ +export class ItemType implements CacheableObject { + /** + * The identifier of this ItemType + */ + id: string; + + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The universally unique identifier of this ItemType + */ + uuid: string; +} diff --git a/src/app/core/shared/item-relationships/relationship-type.model.ts b/src/app/core/shared/item-relationships/relationship-type.model.ts new file mode 100644 index 0000000000..404d8cdb4b --- /dev/null +++ b/src/app/core/shared/item-relationships/relationship-type.model.ts @@ -0,0 +1,75 @@ +import { Observable } from 'rxjs'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { RemoteData } from '../../data/remote-data'; +import { ResourceType } from '../resource-type'; +import { ItemType } from './item-type.model'; + +/** + * Describes a type of Relationship between multiple possible Items + */ +export class RelationshipType implements CacheableObject { + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The label that describes this RelationshipType + */ + label: string; + + /** + * The identifier of this RelationshipType + */ + id: string; + + /** + * The universally unique identifier of this RelationshipType + */ + uuid: string; + + /** + * The label that describes the Relation to the left of this RelationshipType + */ + leftLabel: string; + + /** + * The maximum amount of Relationships allowed to the left of this RelationshipType + */ + leftMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the left of this RelationshipType + */ + leftMinCardinality: number; + + /** + * The label that describes the Relation to the right of this RelationshipType + */ + rightLabel: string; + + /** + * The maximum amount of Relationships allowed to the right of this RelationshipType + */ + rightMaxCardinality: number; + + /** + * The minimum amount of Relationships allowed to the right of this RelationshipType + */ + rightMinCardinality: number; + + /** + * The type of Item found to the left of this RelationshipType + */ + leftType: Observable>; + + /** + * The type of Item found to the right of this RelationshipType + */ + rightType: Observable>; +} diff --git a/src/app/core/shared/item-relationships/relationship.model.ts b/src/app/core/shared/item-relationships/relationship.model.ts new file mode 100644 index 0000000000..df8f04cd8a --- /dev/null +++ b/src/app/core/shared/item-relationships/relationship.model.ts @@ -0,0 +1,55 @@ +import { Observable } from 'rxjs'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { RemoteData } from '../../data/remote-data'; +import { ResourceType } from '../resource-type'; +import { RelationshipType } from './relationship-type.model'; + +/** + * Describes a Relationship between two Items + */ +export class Relationship implements CacheableObject { + /** + * The link to the rest endpoint where this object can be found + */ + self: string; + + /** + * The type of Resource this is + */ + type: ResourceType; + + /** + * The universally unique identifier of this Relationship + */ + uuid: string; + + /** + * The identifier of this Relationship + */ + id: string; + + /** + * The identifier of the Item to the left side of this Relationship + */ + leftId: string; + + /** + * The identifier of the Item to the right side of this Relationship + */ + rightId: string; + + /** + * The place of the Item to the left side of this Relationship + */ + leftPlace: number; + + /** + * The place of the Item to the right side of this Relationship + */ + rightPlace: number; + + /** + * The type of Relationship + */ + relationshipType: Observable>; +} diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 69def7b969..7dadfafdd9 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,5 +1,5 @@ -import {map, startWith, filter} from 'rxjs/operators'; import { Observable } from 'rxjs'; +import { filter, map, startWith, tap } from 'rxjs/operators'; import { DSpaceObject } from './dspace-object.model'; import { Collection } from './collection.model'; @@ -7,6 +7,7 @@ import { RemoteData } from '../data/remote-data'; import { Bitstream } from './bitstream.model'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { PaginatedList } from '../data/paginated-list'; +import { Relationship } from './item-relationships/relationship.model'; export class Item extends DSpaceObject { @@ -51,6 +52,8 @@ export class Item extends DSpaceObject { bitstreams: Observable>>; + relationships: Observable>>; + /** * Retrieves the thumbnail of this item * @returns {Observable} the primaryBitstream of the 'THUMBNAIL' bundle @@ -87,6 +90,8 @@ export class Item extends DSpaceObject { * Retrieves bitstreams by bundle name * @param bundleName The name of the Bundle that should be returned * @returns {Observable} the bitstreams with the given bundleName + * TODO now that bitstreams can be paginated this should move to the server + * see https://github.com/DSpace/dspace-angular/issues/332 */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams.pipe( diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts new file mode 100644 index 0000000000..cd153153de --- /dev/null +++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.spec.ts @@ -0,0 +1,36 @@ +import { MetadataRepresentationType } from '../metadata-representation.model'; +import { ItemMetadataRepresentation, ItemTypeToValue } from './item-metadata-representation.model'; +import { Item } from '../../item.model'; +import { Metadatum } from '../../metadatum.model'; + +describe('ItemMetadataRepresentation', () => { + const valuePrefix = 'Test value for '; + const item = new Item(); + let itemMetadataRepresentation: ItemMetadataRepresentation; + item.metadata = Object.keys(ItemTypeToValue).map((key: string) => { + return Object.assign(new Metadatum(), { + key: ItemTypeToValue[key], + value: `${valuePrefix}${ItemTypeToValue[key]}` + }); + }); + + for (const itemType of Object.keys(ItemTypeToValue)) { + describe(`when creating an ItemMetadataRepresentation with item-type "${itemType}"`, () => { + beforeEach(() => { + itemMetadataRepresentation = Object.assign(new ItemMetadataRepresentation(itemType), item); + }); + + it('should have a representation type of item', () => { + expect(itemMetadataRepresentation.representationType).toEqual(MetadataRepresentationType.Item); + }); + + it('should return the correct value when calling getValue', () => { + expect(itemMetadataRepresentation.getValue()).toEqual(`${valuePrefix}${ItemTypeToValue[itemType]}`); + }); + + it('should return the correct item type', () => { + expect(itemMetadataRepresentation.itemType).toEqual(itemType); + }); + }); + } +}); diff --git a/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts new file mode 100644 index 0000000000..b1c63ebee6 --- /dev/null +++ b/src/app/core/shared/metadata-representation/item/item-metadata-representation.model.ts @@ -0,0 +1,48 @@ +import { Item } from '../../item.model'; +import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; +import { hasValue } from '../../../../shared/empty.util'; + +/** + * An object to convert item types into the metadata field it should render for the item's value + */ +export const ItemTypeToValue = { + Default: 'dc.title', + Person: 'dc.contributor.author' +}; + +/** + * This class determines which fields to use when rendering an Item as a metadata value. + */ +export class ItemMetadataRepresentation extends Item implements MetadataRepresentation { + + /** + * The type of item this item can be represented as + */ + itemType: string; + + constructor(itemType: string) { + super(); + this.itemType = itemType; + } + + /** + * Fetch the way this item should be rendered as in a list + */ + get representationType(): MetadataRepresentationType { + return MetadataRepresentationType.Item; + } + + /** + * Get the value to display, depending on the itemType + */ + getValue(): string { + let metadata; + if (hasValue(ItemTypeToValue[this.itemType])) { + metadata = ItemTypeToValue[this.itemType]; + } else { + metadata = ItemTypeToValue.Default; + } + return this.firstMetadataValue(metadata); + } + +} diff --git a/src/app/core/shared/metadata-representation/metadata-representation.model.ts b/src/app/core/shared/metadata-representation/metadata-representation.model.ts new file mode 100644 index 0000000000..58e5bf906f --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadata-representation.model.ts @@ -0,0 +1,31 @@ +/** + * An Enum defining the representation type of metadata + */ +export enum MetadataRepresentationType { + None = 'none', + Item = 'item', + AuthorityControlled = 'authority_controlled', + PlainText = 'plain_text' +} + +/** + * An interface containing information about how we should represent certain metadata + */ +export interface MetadataRepresentation { + /** + * The type of item this metadata is representing + * e.g. 'Person' + * This can be used for template matching + */ + itemType: string; + + /** + * How we should render the metadata in a list + */ + representationType: MetadataRepresentationType, + + /** + * Fetches the value to be displayed + */ + getValue(): string +} diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts new file mode 100644 index 0000000000..c55ff7f9f3 --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.spec.ts @@ -0,0 +1,54 @@ +import { Metadatum } from '../../metadatum.model'; +import { MetadatumRepresentation } from './metadatum-representation.model'; +import { MetadataRepresentationType } from '../metadata-representation.model'; + +describe('MetadatumRepresentation', () => { + const itemType = 'Person'; + const normalMetadatum = Object.assign(new Metadatum(), { + key: 'dc.contributor.author', + value: 'Test Author' + }); + const authorityMetadatum = Object.assign(new Metadatum(), { + key: 'dc.contributor.author', + value: 'Test Authority Author', + authority: '1234' + }); + + let metadatumRepresentation: MetadatumRepresentation; + + describe('when creating a MetadatumRepresentation based on a standard Metadatum object', () => { + beforeEach(() => { + metadatumRepresentation = Object.assign(new MetadatumRepresentation(itemType), normalMetadatum); + }); + + it('should have a representation type of plain text', () => { + expect(metadatumRepresentation.representationType).toEqual(MetadataRepresentationType.PlainText); + }); + + it('should return the correct value when calling getPrimaryValue', () => { + expect(metadatumRepresentation.getValue()).toEqual(normalMetadatum.value); + }); + + it('should return the correct item type', () => { + expect(metadatumRepresentation.itemType).toEqual(itemType); + }); + }); + + describe('when creating a MetadatumRepresentation based on an authority controlled Metadatum object', () => { + beforeEach(() => { + metadatumRepresentation = Object.assign(new MetadatumRepresentation(itemType), authorityMetadatum); + }); + + it('should have a representation type of plain text', () => { + expect(metadatumRepresentation.representationType).toEqual(MetadataRepresentationType.AuthorityControlled); + }); + + it('should return the correct value when calling getValue', () => { + expect(metadatumRepresentation.getValue()).toEqual(authorityMetadatum.value); + }); + + it('should return the correct item type', () => { + expect(metadatumRepresentation.itemType).toEqual(itemType); + }); + }); +}); diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts new file mode 100644 index 0000000000..595147f3e6 --- /dev/null +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts @@ -0,0 +1,38 @@ +import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; +import { hasValue } from '../../../../shared/empty.util'; +import { MetadataValue } from '../../metadata.models'; + +/** + * This class defines the way the metadatum it extends should be represented + */ +export class MetadatumRepresentation extends MetadataValue implements MetadataRepresentation { + + /** + * The type of item this metadatum can be represented as + */ + itemType: string; + + constructor(itemType: string) { + super(); + this.itemType = itemType; + } + + /** + * Fetch the way this metadatum should be rendered as in a list + */ + get representationType(): MetadataRepresentationType { + if (hasValue(this.authority)) { + return MetadataRepresentationType.AuthorityControlled; + } else { + return MetadataRepresentationType.PlainText; + } + } + + /** + * Get the value to display + */ + getValue(): string { + return this.value; + } + +} diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index b72eb340d3..a83277b882 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -1,7 +1,10 @@ import * as uuidv4 from 'uuid/v4'; import { autoserialize, Serialize, Deserialize } from 'cerialize'; +import { hasValue } from '../../shared/empty.util'; /* tslint:disable:max-classes-per-file */ +const VIRTUAL_METADATA_PREFIX = 'virtual::'; + /** A map of metadata keys to an ordered list of MetadataValue objects. */ export class MetadataMap { [key: string]: MetadataValue[]; @@ -20,6 +23,44 @@ export class MetadataValue { /** The string value. */ @autoserialize value: string; + + /** + * The place of this Metadatum within his list of metadata + * This is used to render metadata in a specific custom order + */ + @autoserialize + place: number; + + /** + * The authority key used for authority-controlled metadata + */ + @autoserialize + authority: string; + + /** + * The authority confidence value + */ + @autoserialize + confidence: number; + + /** + * Returns true if this Metadatum's authority key starts with 'virtual::' + */ + get isVirtual(): boolean { + return hasValue(this.authority) && this.authority.startsWith(VIRTUAL_METADATA_PREFIX); + } + + /** + * If this is a virtual Metadatum, it returns everything in the authority key after 'virtual::'. + * Returns undefined otherwise. + */ + get virtualValue(): string { + if (this.isVirtual) { + return this.authority.substring(this.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length); + } else { + return undefined; + } + } } /** Constraints for matching metadata values. */ @@ -52,6 +93,22 @@ export class MetadatumViewModel { /** The order. */ order: number; + + /** + * The place of this Metadatum within his list of metadata + * This is used to render metadata in a specific custom order + */ + place: number; + + /** + * The authority key used for authority-controlled metadata + */ + authority: string; + + /** + * The authority confidence value + */ + confidence: number; } /** Serializer used for MetadataMaps. diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 1e9446912d..330cbdb32b 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -205,8 +205,8 @@ export class Metadata { .sort() .forEach((key: string) => { const orderedValues = sortBy(groupedList[key], ['order']); - metadataMap[key] = orderedValues.map((value: MetadataValue) => { - const val = Object.assign({}, value); + metadataMap[key] = orderedValues.map((value: MetadatumViewModel) => { + const val = Object.assign(new MetadataValue(), value); delete (val as any).order; delete (val as any).key; return val; diff --git a/src/app/core/shared/metadatum.model.spec.ts b/src/app/core/shared/metadatum.model.spec.ts new file mode 100644 index 0000000000..0b552eed56 --- /dev/null +++ b/src/app/core/shared/metadatum.model.spec.ts @@ -0,0 +1,67 @@ +import { Metadatum } from './metadatum.model'; + +describe('Metadatum', () => { + let metadatum: Metadatum ; + + beforeEach(() => { + metadatum = new Metadatum(); + }); + + describe('isVirtual', () => { + describe('when the metadatum has no authority key', () => { + beforeEach(() => { + metadatum.authority = undefined; + }); + + it('should return false', () => { + expect(metadatum.isVirtual).toBe(false); + }); + }); + + describe('when the metadatum has an authority key', () => { + describe('but it doesn\'t start with the virtual prefix', () => { + beforeEach(() => { + metadatum.authority = 'value'; + }); + + it('should return false', () => { + expect(metadatum.isVirtual).toBe(false); + }); + }); + + describe('and it starts with the virtual prefix', () => { + beforeEach(() => { + metadatum.authority = 'virtual::value'; + }); + + it('should return true', () => { + expect(metadatum.isVirtual).toBe(true); + }); + }); + + }); + + }); + + describe('virtualValue', () => { + describe('when the metadatum isn\'t virtual', () => { + beforeEach(() => { + metadatum.authority = 'value'; + }); + + it('should return undefined', () => { + expect(metadatum.virtualValue).toBeUndefined(); + }); + }); + + describe('when the metadatum is virtual', () => { + beforeEach(() => { + metadatum.authority = 'virtual::value'; + }); + + it('should return everything in the authority key after virtual::', () => { + expect(metadatum.virtualValue).toBe('value'); + }); + }); + }); +}); diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index 88ffa3386e..c876d02a56 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -10,5 +10,8 @@ export enum ResourceType { Group = 'group', ResourcePolicy = 'resourcePolicy', MetadataSchema = 'metadataschema', - MetadataField = 'metadatafield' + MetadataField = 'metadatafield', + Relationship = 'relationship', + RelationshipType = 'relationshiptype', + ItemType = 'entitytype', } diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index c7f41a07a3..fec75b2fd3 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -1,5 +1,6 @@