diff --git a/config/environment.default.js b/config/environment.default.js index b8eae1ccff..e3b5aef21d 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -80,6 +80,15 @@ module.exports = { label: 'Nederlands', active: false, }], + // Browse-By Pages + browseBy: { + // Amount of years to display using jumps of one year (current year - oneYearLimit) + oneYearLimit: 10, + // Limit for years to display using jumps of five years (current year - fiveYearLimit) + fiveYearLimit: 30, + // The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) + defaultLowerLimit: 1900 + }, item: { edit: { undoTimeout: 10000 // 10 seconds diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 208e5415a8..502c638b0e 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -389,15 +389,40 @@ }, "browse": { "title": "Browsing {{ collection }} by {{ field }} {{ value }}", + "startsWith": { + "jump": "Jump to a point in the index:", + "choose_year": "(Choose year)", + "choose_start": "(Choose start)", + "type_date": "Or type in a date (year-month):", + "type_text": "Or enter first few letters:", + "months": { + "none": "(Choose month)", + "january": "January", + "february": "February", + "march": "March", + "april": "April", + "may": "May", + "june": "June", + "july": "July", + "august": "August", + "september": "September", + "october": "October", + "november": "November", + "december": "December" + }, + "submit": "Go" + }, "metadata": { "title": "Title", "author": "Author", - "subject": "Subject" + "subject": "Subject", + "dateissued": "Issue Date" }, "comcol": { "head": "Browse", "by": { "title": "By Title", + "dateissued": "By Issue Date", "author": "By Author", "subject": "By Subject" } @@ -578,7 +603,8 @@ "item": "Loading item...", "objects": "Loading...", "search-results": "Loading search results...", - "browse-by": "Loading items..." + "browse-by": "Loading items...", + "browse-by-page": "Loading page..." }, "error": { "default": "Error", diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts new file mode 100644 index 0000000000..ccf7cde67b --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts @@ -0,0 +1,104 @@ +import { BrowseByDatePageComponent } from './browse-by-date-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BrowseService } from '../../core/browse/browse.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { MockRouter } from '../../shared/mocks/mock-router'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../core/data/remote-data'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { Community } from '../../core/shared/community.model'; +import { Item } from '../../core/shared/item.model'; +import { ENV_CONFIG, GLOBAL_CONFIG } from '../../../config'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec'; + +describe('BrowseByDatePageComponent', () => { + let comp: BrowseByDatePageComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + + const mockCommunity = Object.assign(new Community(), { + id: 'test-uuid', + metadata: [ + { + key: 'dc.title', + value: 'test community' + } + ] + }); + + const firstItem = Object.assign(new Item(), { + id: 'first-item-id', + metadata: { + 'dc.date.issued': [ + { + value: '1950-01-01' + } + ] + } + }); + + const mockBrowseService = { + getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]), + getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]), + getFirstItemFor: () => observableOf(new RemoteData(false, false, true, undefined, firstItem)) + }; + + const mockDsoService = { + findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity)) + }; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + queryParams: observableOf({}), + data: observableOf({ metadata: 'dateissued', metadataField: 'dc.date.issued' }) + }); + + const mockCdRef = Object.assign({ + detectChanges: () => fixture.detectChanges() + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByDatePageComponent, EnumKeysPipe], + providers: [ + { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() }, + { provide: ChangeDetectorRef, useValue: mockCdRef } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByDatePageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + }); + + it('should initialize the list of items', () => { + comp.items$.subscribe((result) => { + expect(result.payload.page).toEqual([firstItem]); + }); + }); + + it('should create a list of startsWith options with the earliest year at the end (rounded down by 10)', () => { + expect(comp.startsWithOptions[comp.startsWithOptions.length - 1]).toEqual(1950); + }); + + it('should create a list of startsWith options with the current year first', () => { + expect(comp.startsWithOptions[0]).toEqual(new Date().getFullYear()); + }); +}); diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts new file mode 100644 index 0000000000..29a3f9ad31 --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -0,0 +1,116 @@ +import { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { + BrowseByMetadataPageComponent, + browseParamsToOptions +} from '../+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { Item } from '../../core/shared/item.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BrowseService } from '../../core/browse/browse.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; + +@Component({ + selector: 'ds-browse-by-date-page', + styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'], + templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html' +}) +/** + * Component for browsing items by metadata definition of type 'date' + * A metadata definition is a short term used to describe one or multiple metadata fields. + * An example would be 'dateissued' for 'dc.date.issued' + */ +export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { + + /** + * The default metadata-field to use for determining the lower limit of the StartsWith dropdown options + */ + defaultMetadataField = 'dc.date.issued'; + + public constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig, + protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router, + protected cdRef: ChangeDetectorRef) { + super(route, browseService, dsoService, router); + } + + ngOnInit(): void { + this.startsWithType = StartsWithType.date; + this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); + this.subs.push( + observableCombineLatest( + this.route.params, + this.route.queryParams, + this.route.data, + (params, queryParams, data ) => { + return Object.assign({}, params, queryParams, data); + }) + .subscribe((params) => { + const metadataField = params.metadataField || this.defaultMetadataField; + this.metadata = params.metadata || this.defaultMetadata; + this.startsWith = +params.startsWith || params.startsWith; + const searchOptions = browseParamsToOptions(params, Object.assign({}), this.sortConfig, this.metadata); + this.updatePageWithItems(searchOptions, this.value); + this.updateParent(params.scope); + this.updateStartsWithOptions(this.metadata, metadataField, params.scope); + })); + } + + /** + * Update the StartsWith options + * In this implementation, it creates a list of years starting from now, going all the way back to the earliest + * date found on an item within this scope. The further back in time, the bigger the change in years become to avoid + * extremely long lists with a one-year difference. + * To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this. + * @param definition The metadata definition to fetch the first item for + * @param metadataField The metadata field to fetch the earliest date from (expects a date field) + * @param scope The scope under which to fetch the earliest item for + */ + updateStartsWithOptions(definition: string, metadataField: string, scope?: string) { + this.subs.push( + this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData) => { + let lowerLimit = this.config.browseBy.defaultLowerLimit; + if (hasValue(firstItemRD.payload)) { + const date = firstItemRD.payload.firstMetadataValue(metadataField); + if (hasValue(date) && hasValue(+date.split('-')[0])) { + lowerLimit = +date.split('-')[0]; + } + } + const options = []; + const currentYear = new Date().getFullYear(); + const oneYearBreak = Math.floor((currentYear - this.config.browseBy.oneYearLimit) / 5) * 5; + const fiveYearBreak = Math.floor((currentYear - this.config.browseBy.fiveYearLimit) / 10) * 10; + if (lowerLimit <= fiveYearBreak) { + lowerLimit -= 10; + } else if (lowerLimit <= oneYearBreak) { + lowerLimit -= 5; + } else { + lowerLimit -= 1; + } + let i = currentYear; + while (i > lowerLimit) { + options.push(i); + if (i <= fiveYearBreak) { + i -= 10; + } else if (i <= oneYearBreak) { + i -= 5; + } else { + i--; + } + } + if (isNotEmpty(options)) { + this.startsWithOptions = options; + this.cdRef.detectChanges(); + } + }) + ); + } + +} diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html index 08fb762db0..cf43f74eb0 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html @@ -1,10 +1,18 @@
-
diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts index a53faf6b8b..98d7299984 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts @@ -19,6 +19,7 @@ import { SortDirection } from '../../core/cache/models/sort-options.model'; import { Item } from '../../core/shared/item.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { Community } from '../../core/shared/community.model'; +import { MockRouter } from '../../shared/mocks/mock-router'; describe('BrowseByMetadataPageComponent', () => { let comp: BrowseByMetadataPageComponent; @@ -86,7 +87,8 @@ describe('BrowseByMetadataPageComponent', () => { providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, - { provide: DSpaceObjectDataService, useValue: mockDsoService } + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index 87ccb20c0b..52c63341bd 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -4,17 +4,17 @@ import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { BrowseService } from '../../core/browse/browse.service'; import { BrowseEntry } from '../../core/shared/browse-entry.model'; import { Item } from '../../core/shared/item.model'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; -import { Community } from '../../core/shared/community.model'; -import { Collection } from '../../core/shared/collection.model'; import { getSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { take } from 'rxjs/operators'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by-metadata-page', @@ -72,6 +72,18 @@ export class BrowseByMetadataPageComponent implements OnInit { */ metadata = this.defaultMetadata; + /** + * The type of StartsWith options to render + * Defaults to text + */ + startsWithType = StartsWithType.text; + + /** + * The list of StartsWith options + * Should be defined after ngOnInit is called! + */ + startsWithOptions; + /** * The value we're browing items for * - When the value is not empty, we're browsing items @@ -79,9 +91,15 @@ export class BrowseByMetadataPageComponent implements OnInit { */ value = ''; - public constructor(private route: ActivatedRoute, - private browseService: BrowseService, - private dsoService: DSpaceObjectDataService) { + /** + * The current startsWith option (fetched and updated from query-params) + */ + startsWith: string; + + public constructor(protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router) { } ngOnInit(): void { @@ -96,6 +114,7 @@ export class BrowseByMetadataPageComponent implements OnInit { .subscribe((params) => { this.metadata = params.metadata || this.defaultMetadata; this.value = +params.value || params.value || ''; + this.startsWith = +params.startsWith || params.startsWith; const searchOptions = browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata); if (isNotEmpty(this.value)) { this.updatePageWithItems(searchOptions, this.value); @@ -104,6 +123,15 @@ export class BrowseByMetadataPageComponent implements OnInit { } this.updateParent(params.scope); })); + this.updateStartsWithTextOptions(); + } + + /** + * Update the StartsWith options with text values + * It adds the value "0-9" as well as all letters from A to Z + */ + updateStartsWithTextOptions() { + this.startsWithOptions = ['0-9', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; } /** @@ -144,6 +172,58 @@ export class BrowseByMetadataPageComponent implements OnInit { } } + /** + * Navigate to the previous page + */ + goPrev() { + if (this.items$) { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getPrevBrowseItems(items); + }); + } else if (this.browseEntries$) { + this.browseEntries$.pipe(take(1)).subscribe((entries) => { + this.browseEntries$ = this.browseService.getPrevBrowseEntries(entries); + }); + } + } + + /** + * Navigate to the next page + */ + goNext() { + if (this.items$) { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getNextBrowseItems(items); + }); + } else if (this.browseEntries$) { + this.browseEntries$.pipe(take(1)).subscribe((entries) => { + this.browseEntries$ = this.browseService.getNextBrowseEntries(entries); + }); + } + } + + /** + * Change the page size + * @param size + */ + pageSizeChange(size) { + this.router.navigate([], { + queryParams: Object.assign({ pageSize: size }), + queryParamsHandling: 'merge' + }); + } + + /** + * Change the sorting direction + * @param direction + */ + sortDirectionChange(direction) { + this.router.navigate([], { + queryParams: Object.assign({ sortDirection: direction }), + queryParamsHandling: 'merge' + }); + } + ngOnDestroy(): void { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } @@ -177,6 +257,7 @@ export function browseParamsToOptions(params: any, field: params.sortField || sortConfig.field } ), + +params.startsWith || params.startsWith, params.scope ); } diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html deleted file mode 100644 index 84b0baf1f6..0000000000 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
- - -
-
diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts index c92e5c64cb..855101bb9d 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts @@ -1,5 +1,5 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Item } from '../../core/shared/item.model'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { of as observableOf } from 'rxjs/internal/observable/of'; @@ -15,6 +15,8 @@ import { ItemDataService } from '../../core/data/item-data.service'; import { Community } from '../../core/shared/community.model'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { BrowseService } from '../../core/browse/browse.service'; +import { MockRouter } from '../../shared/mocks/mock-router'; describe('BrowseByTitlePageComponent', () => { let comp: BrowseByTitlePageComponent; @@ -44,8 +46,9 @@ describe('BrowseByTitlePageComponent', () => { }) ]; - const mockItemDataService = { - findAll: () => toRemoteData(mockItems) + const mockBrowseService = { + getBrowseItemsFor: () => toRemoteData(mockItems), + getBrowseEntriesFor: () => toRemoteData([]) }; const mockDsoService = { @@ -53,7 +56,8 @@ describe('BrowseByTitlePageComponent', () => { }; const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: observableOf({}) + params: observableOf({}), + data: observableOf({ metadata: 'title' }) }); beforeEach(async(() => { @@ -62,8 +66,9 @@ describe('BrowseByTitlePageComponent', () => { declarations: [BrowseByTitlePageComponent, EnumKeysPipe], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, - { provide: ItemDataService, useValue: mockItemDataService }, - { provide: DSpaceObjectDataService, useValue: mockDsoService } + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts index 6ba43c8f10..717275bf8b 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -1,107 +1,51 @@ -import { combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs'; -import { Component, OnInit } from '@angular/core'; -import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { Component } from '@angular/core'; import { ItemDataService } from '../../core/data/item-data.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { Item } from '../../core/shared/item.model'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { hasValue } from '../../shared/empty.util'; -import { Collection } from '../../core/shared/collection.model'; -import { browseParamsToOptions } from '../+browse-by-metadata-page/browse-by-metadata-page.component'; +import { + BrowseByMetadataPageComponent, + browseParamsToOptions +} from '../+browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; -import { Community } from '../../core/shared/community.model'; -import { getSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { BrowseService } from '../../core/browse/browse.service'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; @Component({ selector: 'ds-browse-by-title-page', - styleUrls: ['./browse-by-title-page.component.scss'], - templateUrl: './browse-by-title-page.component.html' + styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'], + templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html' }) /** * Component for browsing items by title (dc.title) */ -export class BrowseByTitlePageComponent implements OnInit { - - /** - * The list of items to display - */ - items$: Observable>>; - - /** - * The current Community or Collection we're browsing metadata/items in - */ - parent$: Observable>; - - /** - * The pagination configuration to use for displaying the list of items - */ - paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'browse-by-title-pagination', - currentPage: 1, - pageSize: 20 - }); - - /** - * The sorting configuration to use for displaying the list of items - * Sorted by title (Ascending by default) - */ - sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC); - - /** - * List of subscriptions - */ - subs: Subscription[] = []; - - public constructor(private itemDataService: ItemDataService, - private route: ActivatedRoute, - private dsoService: DSpaceObjectDataService) { +export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { + public constructor(protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router) { + super(route, browseService, dsoService, router); } ngOnInit(): void { + this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); this.subs.push( observableCombineLatest( this.route.params, this.route.queryParams, - (params, queryParams, ) => { - return Object.assign({}, params, queryParams); + this.route.data, + (params, queryParams, data ) => { + return Object.assign({}, params, queryParams, data); }) .subscribe((params) => { - this.updatePage(browseParamsToOptions(params, this.paginationConfig, this.sortConfig)); + this.metadata = params.metadata || this.defaultMetadata; + this.updatePageWithItems(browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata), undefined); this.updateParent(params.scope) })); - } - - /** - * Updates the current page with searchOptions - * @param searchOptions Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } - */ - updatePage(searchOptions: BrowseEntrySearchOptions) { - this.items$ = this.itemDataService.findAll({ - currentPage: searchOptions.pagination.currentPage, - elementsPerPage: searchOptions.pagination.pageSize, - sort: searchOptions.sort, - scopeID: searchOptions.scope - }); - } - - /** - * Update the parent Community or Collection using their scope - * @param scope The UUID of the Community or Collection to fetch - */ - updateParent(scope: string) { - if (hasValue(scope)) { - this.parent$ = this.dsoService.findById(scope).pipe( - getSucceededRemoteData() - ); - } + this.updateStartsWithTextOptions(); } ngOnDestroy(): void { diff --git a/src/app/+browse-by/browse-by-guard.ts b/src/app/+browse-by/browse-by-guard.ts new file mode 100644 index 0000000000..30f5d69ffc --- /dev/null +++ b/src/app/+browse-by/browse-by-guard.ts @@ -0,0 +1,58 @@ +import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; +import { Injectable } from '@angular/core'; +import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service'; +import { hasValue } from '../shared/empty.util'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable() +/** + * A guard taking care of the correct route.data being set for the Browse-By components + */ +export class BrowseByGuard implements CanActivate { + + constructor(protected dsoService: DSpaceObjectDataService, + protected translate: TranslateService) { + } + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + const title = route.data.title; + const metadata = route.params.metadata || route.queryParams.metadata || route.data.metadata; + const metadataField = route.data.metadataField; + const scope = route.queryParams.scope; + const value = route.queryParams.value; + + const metadataTranslated$ = this.translate.get('browse.metadata.' + metadata).pipe(take(1)); + + if (hasValue(scope)) { + const dsoAndMetadata$ = observableCombineLatest(metadataTranslated$, this.dsoService.findById(scope).pipe(getSucceededRemoteData())); + return dsoAndMetadata$.pipe( + map(([metadataTranslated, dsoRD]) => { + const name = dsoRD.payload.name; + route.data = this.createData(title, metadata, metadataField, name, metadataTranslated, value);; + return true; + }) + ); + } else { + return metadataTranslated$.pipe( + map((metadataTranslated: string) => { + route.data = this.createData(title, metadata, metadataField, '', metadataTranslated, value); + return true; + }) + ) + } + } + + private createData(title, metadata, metadataField, collection, field, value) { + return { + title: title, + metadata: metadata, + metadataField: metadataField, + collection: collection, + field: field, + value: hasValue(value) ? `"${value}"` : '' + } + } +} diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts index 38915fffca..9ba15ecfe9 100644 --- a/src/app/+browse-by/browse-by-routing.module.ts +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -2,12 +2,15 @@ import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component'; +import { BrowseByGuard } from './browse-by-guard'; @NgModule({ imports: [ RouterModule.forChild([ - { path: 'title', component: BrowseByTitlePageComponent }, - { path: ':metadata', component: BrowseByMetadataPageComponent } + { path: 'title', component: BrowseByTitlePageComponent, canActivate: [BrowseByGuard], data: { metadata: 'title', title: 'browse.title' } }, + { path: 'dateissued', component: BrowseByDatePageComponent, canActivate: [BrowseByGuard], data: { metadata: 'dateissued', metadataField: 'dc.date.issued', title: 'browse.title' } }, + { path: ':metadata', component: BrowseByMetadataPageComponent, canActivate: [BrowseByGuard], data: { title: 'browse.title' } } ]) ] }) diff --git a/src/app/+browse-by/browse-by.module.ts b/src/app/+browse-by/browse-by.module.ts index 38e5001b80..30d4617c16 100644 --- a/src/app/+browse-by/browse-by.module.ts +++ b/src/app/+browse-by/browse-by.module.ts @@ -6,6 +6,8 @@ import { SharedModule } from '../shared/shared.module'; import { BrowseByRoutingModule } from './browse-by-routing.module'; import { BrowseService } from '../core/browse/browse.service'; import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component'; +import { BrowseByGuard } from './browse-by-guard'; @NgModule({ imports: [ @@ -15,11 +17,13 @@ import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse ], declarations: [ BrowseByTitlePageComponent, - BrowseByMetadataPageComponent + BrowseByMetadataPageComponent, + BrowseByDatePageComponent ], providers: [ ItemDataService, - BrowseService + BrowseService, + BrowseByGuard ] }) export class BrowseByModule { diff --git a/src/app/core/browse/browse-entry-search-options.model.ts b/src/app/core/browse/browse-entry-search-options.model.ts index a4911a33f1..417bf7ce75 100644 --- a/src/app/core/browse/browse-entry-search-options.model.ts +++ b/src/app/core/browse/browse-entry-search-options.model.ts @@ -12,6 +12,7 @@ export class BrowseEntrySearchOptions { constructor(public metadataDefinition: string, public pagination?: PaginationComponentOptions, public sort?: SortOptions, + public startsWith?: string, public scope?: string) { } } diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index d37a87c69e..14b023e362 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -281,4 +281,38 @@ describe('BrowseService', () => { }); }); }); + + describe('getFirstItemFor', () => { + beforeEach(() => { + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + service = initTestService(); + spyOn(service, 'getBrowseDefinitions').and + .returnValue(hot('--a-', { a: { + payload: browseDefinitions + }})); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + }); + + describe('when getFirstItemFor is called with a valid browse definition id', () => { + const expectedURL = browseDefinitions[1]._links.items + '?page=0&size=1'; + + it('should configure a new BrowseItemsRequest', () => { + const expected = new BrowseItemsRequest(requestService.generateRequestId(), expectedURL); + + scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getFirstItemFor(browseDefinitions[1].id); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + }); + + }); + }); + }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index ef4fdaa5ff..bf368e37ce 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,17 +1,14 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, map, startWith, take } from 'rxjs/operators'; import { - ensureArrayHasValue, + ensureArrayHasValue, hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortOptions } from '../cache/models/sort-options.model'; -import { GenericSuccessResponse } from '../cache/response.models'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; import { @@ -26,17 +23,19 @@ import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { configureRequest, - filterSuccessfulResponses, - getBrowseDefinitionLinks, - getRemoteDataPayload, getRequestFromRequestHref + filterSuccessfulResponses, getBrowseDefinitionLinks, getFirstOccurrence, + getRemoteDataPayload, + getRequestFromRequestHref } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { Item } from '../shared/item.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; +import { GenericSuccessResponse } from '../cache/response.models'; +import { RequestEntry } from '../data/request.reducer'; /** - * Service that performs all actions that have to do with browse. + * The service handling all browse requests */ @Injectable() export class BrowseService { @@ -62,6 +61,9 @@ export class BrowseService { ) { } + /** + * Get all BrowseDefinitions + */ getBrowseDefinitions(): Observable> { const request$ = this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), @@ -84,8 +86,12 @@ export class BrowseService { return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } + /** + * Get all BrowseEntries filtered or modified by BrowseEntrySearchOptions + * @param options + */ getBrowseEntriesFor(options: BrowseEntrySearchOptions): Observable>> { - const request$ = this.getBrowseDefinitions().pipe( + return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), map((_links: any) => _links.entries), @@ -93,7 +99,7 @@ export class BrowseService { map((href: string) => { // TODO nearly identical to PaginatedSearchOptions => refactor const args = []; - if (isNotEmpty(options.sort)) { + if (isNotEmpty(options.scope)) { args.push(`scope=${options.scope}`); } if (isNotEmpty(options.sort)) { @@ -103,49 +109,33 @@ export class BrowseService { args.push(`page=${options.pagination.currentPage - 1}`); args.push(`size=${options.pagination.pageSize}`); } + if (isNotEmpty(options.startsWith)) { + args.push(`startsWith=${options.startsWith}`); + } if (isNotEmpty(args)) { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; }), - map((endpointURL: string) => new BrowseEntriesRequest(this.requestService.generateRequestId(), endpointURL)), - configureRequest(this.requestService) + getBrowseEntriesFor(this.requestService, this.rdb) ); - - const href$ = request$.pipe(map((request: RestRequest) => request.href)); - - const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); - - const payload$ = requestEntry$.pipe( - filterSuccessfulResponses(), - map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), - map((list: PaginatedList) => Object.assign(list, { - page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page - })), - distinctUntilChanged() - ); - - return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } /** * Get all items linked to a certain metadata value - * @param {string} definitionID definition ID to define the metadata-field (e.g. author) * @param {string} filterValue metadata value to filter by (e.g. author's name) - * @param options Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } + * @param options Options to narrow down your search * @returns {Observable>>} */ getBrowseItemsFor(filterValue: string, options: BrowseEntrySearchOptions): Observable>> { - const request$ = this.getBrowseDefinitions().pipe( + return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), map((_links: any) => _links.items), hasValueOperator(), map((href: string) => { const args = []; - if (isNotEmpty(options.sort)) { + if (isNotEmpty(options.scope)) { args.push(`scope=${options.scope}`); } if (isNotEmpty(options.sort)) { @@ -155,6 +145,9 @@ export class BrowseService { args.push(`page=${options.pagination.currentPage - 1}`); args.push(`size=${options.pagination.pageSize}`); } + if (isNotEmpty(options.startsWith)) { + args.push(`startsWith=${options.startsWith}`); + } if (isNotEmpty(filterValue)) { args.push(`filterValue=${filterValue}`); } @@ -163,26 +156,83 @@ export class BrowseService { } return href; }), - map((endpointURL: string) => new BrowseItemsRequest(this.requestService.generateRequestId(), endpointURL)), - configureRequest(this.requestService) + getBrowseItemsFor(this.requestService, this.rdb) ); - - const href$ = request$.pipe(map((request: RestRequest) => request.href)); - - const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); - - const payload$ = requestEntry$.pipe( - filterSuccessfulResponses(), - map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), - map((list: PaginatedList) => Object.assign(list, { - page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page - })), - distinctUntilChanged() - ); - - return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } + /** + * Get the first item for a metadata definition in an optional scope + * @param definition + * @param scope + */ + getFirstItemFor(definition: string, scope?: string): Observable> { + return this.getBrowseDefinitions().pipe( + getBrowseDefinitionLinks(definition), + hasValueOperator(), + map((_links: any) => _links.items), + hasValueOperator(), + map((href: string) => { + const args = []; + if (hasValue(scope)) { + args.push(`scope=${scope}`); + } + args.push('page=0'); + args.push('size=1'); + if (isNotEmpty(args)) { + href = new URLCombiner(href, `?${args.join('&')}`).toString(); + } + return href; + }), + getBrowseItemsFor(this.requestService, this.rdb), + getFirstOccurrence() + ); + } + + /** + * Get the previous page of items using the paginated list's prev link + * @param items + */ + getPrevBrowseItems(items: RemoteData>): Observable>> { + return observableOf(items.payload.prev).pipe( + getBrowseItemsFor(this.requestService, this.rdb) + ); + } + + /** + * Get the next page of items using the paginated list's next link + * @param items + */ + getNextBrowseItems(items: RemoteData>): Observable>> { + return observableOf(items.payload.next).pipe( + getBrowseItemsFor(this.requestService, this.rdb) + ); + } + + /** + * Get the previous page of browse-entries using the paginated list's prev link + * @param entries + */ + getPrevBrowseEntries(entries: RemoteData>): Observable>> { + return observableOf(entries.payload.prev).pipe( + getBrowseEntriesFor(this.requestService, this.rdb) + ); + } + + /** + * Get the next page of browse-entries using the paginated list's next link + * @param entries + */ + getNextBrowseEntries(entries: RemoteData>): Observable>> { + return observableOf(entries.payload.next).pipe( + getBrowseEntriesFor(this.requestService, this.rdb) + ); + } + + /** + * Get the browse URL by providing a metadatum key and linkPath + * @param metadatumKey + * @param linkPath + */ getBrowseURLFor(metadataKey: string, linkPath: string): Observable { const searchKeyArray = BrowseService.toSearchKeyArray(metadataKey); return this.getBrowseDefinitions().pipe( @@ -206,3 +256,79 @@ export class BrowseService { } } + +/** + * Operator for turning a href into a PaginatedList of BrowseEntries + * @param requestService + * @param responseCache + * @param rdb + */ +export const getBrowseEntriesFor = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => + source.pipe( + map((href: string) => new BrowseEntriesRequest(requestService.generateRequestId(), href)), + configureRequest(requestService), + toRDPaginatedBrowseEntries(requestService, rdb) + ); + +/** + * Operator for turning a href into a PaginatedList of Items + * @param requestService + * @param responseCache + * @param rdb + */ +export const getBrowseItemsFor = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => + source.pipe( + map((href: string) => new BrowseItemsRequest(requestService.generateRequestId(), href)), + configureRequest(requestService), + toRDPaginatedBrowseItems(requestService, rdb) + ); + +/** + * Operator for turning a RestRequest into a PaginatedList of Items + * @param requestService + * @param responseCache + * @param rdb + */ +export const toRDPaginatedBrowseItems = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => { + const href$ = source.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page + })), + distinctUntilChanged() + ); + + return rdb.toRemoteDataObservable(requestEntry$, payload$); + }; + +/** + * Operator for turning a RestRequest into a PaginatedList of BrowseEntries + * @param requestService + * @param responseCache + * @param rdb + */ +export const toRDPaginatedBrowseEntries = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => { + const href$ = source.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page + })), + distinctUntilChanged() + ); + + return rdb.toRemoteDataObservable(requestEntry$, payload$); + }; diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts index 218c25bac6..b1feb2ab7f 100644 --- a/src/app/core/data/browse-items-response-parsing-service.ts +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@angular/core'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ErrorResponse, @@ -15,6 +15,7 @@ import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { Item } from '../shared/item.model'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; /** * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to Browse Items (DSpaceObject[]) @@ -42,9 +43,11 @@ export class BrowseItemsResponseParsingService extends BaseResponseParsingServic parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceRESTv2Serializer(DSpaceObject); + const serializer = new DSpaceRESTv2Serializer(NormalizedDSpaceObject); const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); return new GenericSuccessResponse(items, data.statusCode, this.processPageInfo(data.payload)); + } else if (hasValue(data.payload) && hasValue(data.payload.page)) { + return new GenericSuccessResponse([], data.statusCode, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 736bf11923..a95fc73d33 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -81,7 +81,7 @@ export class MetadataService { this.clearMetaTags(); } if (routeInfo.data.value.title) { - this.translate.get(routeInfo.data.value.title).pipe(take(1)).subscribe((translatedTitle: string) => { + this.translate.get(routeInfo.data.value.title, routeInfo.data.value).pipe(take(1)).subscribe((translatedTitle: string) => { this.addMetaTag('title', translatedTitle); this.title.setTitle(translatedTitle); }); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 3b92c71433..a2c421255e 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -91,7 +91,7 @@ export const getBrowseDefinitionLinks = (definitionID: string) => source.pipe( getRemoteDataPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions - .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) + .find((def: BrowseDefinition) => def.id === definitionID) ), map((def: BrowseDefinition) => { if (isNotEmpty(def)) { @@ -101,3 +101,12 @@ export const getBrowseDefinitionLinks = (definitionID: string) => } }) ); + +/** + * Get the first occurrence of an object within a paginated list + */ +export const getFirstOccurrence = () => + (source: Observable>>): Observable> => + source.pipe( + map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined })) + ); diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index df6226aedc..008a86599d 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -73,6 +73,17 @@ export class NavbarComponent extends MenuComponent implements OnInit { link: '/browse/title' } as LinkMenuItemModel, }, + { + id: 'browse_global_global_by_issue_date', + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_issue_date', + link: '/browse/dateissued' + } as LinkMenuItemModel, + }, { id: 'browse_global_by_author', parentID: 'browse_global', diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index fc3300ae72..bad9f3fe8c 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -1,11 +1,38 @@

{{title | translate}}

+
- - +
+ + +
+
+
+
+
+ +
+ + + + +
+
+
+
+
    +
  • + +
  • +
+
+ + +
+
diff --git a/src/app/shared/browse-by/browse-by.component.scss b/src/app/shared/browse-by/browse-by.component.scss index e69de29bb2..5d847a8609 100644 --- a/src/app/shared/browse-by/browse-by.component.scss +++ b/src/app/shared/browse-by/browse-by.component.scss @@ -0,0 +1,8 @@ +:host { + .dropdown-toggle::after { + display: none; + } + .dropdown-item { + padding-left: 20px; + } +} diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 2417dde7ca..bae345d009 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -1,19 +1,68 @@ import { BrowseByComponent } from './browse-by.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of as observableOf } from 'rxjs'; import { SharedModule } from '../shared.module'; +import { CommonModule } from '@angular/common'; +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 { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { StoreModule } from '@ngrx/store'; +import { MockTranslateLoader } from '../mocks/mock-translate-loader'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; describe('BrowseByComponent', () => { let comp: BrowseByComponent; let fixture: ComponentFixture; + const mockItems = [ + Object.assign(new Item(), { + id: 'fakeId-1', + metadata: [ + { + key: 'dc.title', + value: 'First Fake Title' + } + ] + }), + Object.assign(new Item(), { + id: 'fakeId-2', + metadata: [ + { + key: 'dc.title', + value: 'Second Fake Title' + } + ] + }) + ]; + const mockItemsRD$ = observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), mockItems))); + beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule], + imports: [ + CommonModule, + TranslateModule.forRoot(), + SharedModule, + NgbModule.forRoot(), + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + RouterTestingModule, + BrowserAnimationsModule + ], declarations: [], + providers: [], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); @@ -41,4 +90,67 @@ describe('BrowseByComponent', () => { expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).toBeDefined(); }); + describe('when enableArrows is true and objects are defined', () => { + beforeEach(() => { + comp.enableArrows = true; + comp.objects$ = mockItemsRD$; + comp.paginationConfig = Object.assign(new PaginationComponentOptions(), { + id: 'test-pagination', + currentPage: 1, + pageSizeOptions: [5,10,15,20], + pageSize: 15 + }); + comp.sortConfig = Object.assign(new SortOptions('dc.title', SortDirection.ASC)); + fixture.detectChanges(); + }); + + describe('when clicking the previous arrow button', () => { + beforeEach(() => { + spyOn(comp.prev, 'emit'); + fixture.debugElement.query(By.css('#nav-prev')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.prev.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking the next arrow button', () => { + beforeEach(() => { + spyOn(comp.next, 'emit'); + fixture.debugElement.query(By.css('#nav-next')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.next.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking a different page size', () => { + beforeEach(() => { + spyOn(comp.pageSizeChange, 'emit'); + fixture.debugElement.query(By.css('.page-size-change')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.pageSizeChange.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking a different sort direction', () => { + beforeEach(() => { + spyOn(comp.sortDirectionChange, 'emit'); + fixture.debugElement.query(By.css('.sort-direction-change')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.sortDirectionChange.emit).toHaveBeenCalled(); + }); + }); + }); + }); diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 2e9e825a6b..6c4bc78213 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -1,11 +1,12 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Injector, Input, OnInit, Output } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; -import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { fadeIn, fadeInOut } from '../animations/fade'; import { Observable } from 'rxjs'; import { ListableObject } from '../object-collection/shared/listable-object.model'; +import { getStartsWithComponent, StartsWithType } from '../starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by', @@ -19,7 +20,7 @@ import { ListableObject } from '../object-collection/shared/listable-object.mode /** * Component to display a browse-by page for any ListableObject */ -export class BrowseByComponent { +export class BrowseByComponent implements OnInit { /** * The i18n message to display as title */ @@ -39,4 +40,106 @@ export class BrowseByComponent { * The sorting configuration used for the list */ @Input() sortConfig: SortOptions; + + /** + * The type of StartsWith options used to define what component to render for the options + * Defaults to text + */ + @Input() type = StartsWithType.text; + + /** + * The list of options to render for the StartsWith component + */ + @Input() startsWithOptions = []; + + /** + * Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination + */ + @Input() enableArrows = false; + + /** + * If enableArrows is set to true, should it hide the options gear? + */ + @Input() hideGear = false; + + /** + * If enableArrows is set to true, emit when the previous button is clicked + */ + @Output() prev = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the next button is clicked + */ + @Output() next = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the page size is changed + */ + @Output() pageSizeChange = new EventEmitter(); + + /** + * If enableArrows is set to true, emit when the sort direction is changed + */ + @Output() sortDirectionChange = new EventEmitter(); + + /** + * An object injector used to inject the startsWithOptions to the switchable StartsWith component + */ + objectInjector: Injector; + + /** + * Declare SortDirection enumeration to use it in the template + */ + public sortDirections = SortDirection; + + public constructor(private injector: Injector) { + + } + + /** + * Go to the previous page + */ + goPrev() { + this.prev.emit(true); + } + + /** + * Go to the next page + */ + goNext() { + this.next.emit(true); + } + + /** + * Change the page size + * @param size + */ + doPageSizeChange(size) { + this.paginationConfig.pageSize = size; + this.pageSizeChange.emit(size); + } + + /** + * Change the sort direction + * @param direction + */ + doSortDirectionChange(direction) { + this.sortConfig.direction = direction; + this.sortDirectionChange.emit(direction); + } + + /** + * Get the switchable StartsWith component dependant on the type + */ + getStartsWithComponent() { + return getStartsWithComponent(this.type); + } + + ngOnInit(): void { + this.objectInjector = Injector.create({ + providers: [{ provide: 'startsWithOptions', useFactory: () => (this.startsWithOptions), deps:[] }], + parent: this.injector + }); + } + } diff --git a/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html index 653bd1ed53..f9ef4e5232 100644 --- a/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html +++ b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html @@ -1,6 +1,7 @@

{{'browse.comcol.head' | translate}}

diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html index 6139e4a9df..0fad726777 100644 --- a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html @@ -1,5 +1,5 @@
- + {{object.value}}   diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 5ae3e517e3..0ad4bd0b04 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -95,6 +95,8 @@ import { ObjectValuesPipe } from './utils/object-values-pipe'; import { InListValidator } from './utils/in-list-validator.directive'; import { AutoFocusDirective } from './utils/auto-focus.directive'; import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component'; +import { StartsWithDateComponent } from './starts-with/date/starts-with-date.component'; +import { StartsWithTextComponent } from './starts-with/text/starts-with-text.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -189,7 +191,9 @@ const ENTRY_COMPONENTS = [ CollectionGridElementComponent, CommunityGridElementComponent, SearchResultGridElementComponent, - BrowseEntryListElementComponent + BrowseEntryListElementComponent, + StartsWithDateComponent, + StartsWithTextComponent ]; const PROVIDERS = [ diff --git a/src/app/shared/starts-with/date/starts-with-date.component.html b/src/app/shared/starts-with/date/starts-with-date.component.html new file mode 100644 index 0000000000..3f024c3254 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.html @@ -0,0 +1,34 @@ +
+
+ + {{'browse.startsWith.jump' | translate}} + +
+ +
+
+ +
+
+ + + + +
+
+
diff --git a/src/app/shared/starts-with/date/starts-with-date.component.scss b/src/app/shared/starts-with/date/starts-with-date.component.scss new file mode 100644 index 0000000000..ceec56c8c2 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/starts-with/date/starts-with-date.component.spec.ts b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts new file mode 100644 index 0000000000..10a168ab05 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts @@ -0,0 +1,183 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { By } from '@angular/platform-browser'; +import { StartsWithDateComponent } from './starts-with-date.component'; +import { ActivatedRouteStub } from '../../testing/active-router-stub'; +import { EnumKeysPipe } from '../../utils/enum-keys-pipe'; +import { RouterStub } from '../../testing/router-stub'; + +describe('StartsWithDateComponent', () => { + let comp: StartsWithDateComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let router: Router; + + const options = [2019, 2018, 2017, 2016, 2015]; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + queryParams: observableOf({}) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [StartsWithDateComponent, EnumKeysPipe], + providers: [ + { provide: 'startsWithOptions', useValue: options }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Router, useValue: new RouterStub() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StartsWithDateComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + router = (comp as any).router; + }); + + it('should create a FormGroup containing a startsWith FormControl', () => { + expect(comp.formData.value.startsWith).toBeDefined(); + }); + + describe('when selecting the first option in the year dropdown', () => { + let select; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select#year-select')).nativeElement; + select.value = select.options[0].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to undefined', () => { + expect(comp.startsWith).toBeUndefined(); + }); + + it('should not add a startsWith query parameter', () => { + route.queryParams.subscribe((params) => { + expect(params.startsWith).toBeUndefined(); + }); + }); + }); + + describe('when selecting the second option in the year dropdown', () => { + let select; + let input; + let expectedValue; + let extras; + + beforeEach(() => { + expectedValue = '' + options[0]; + extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + select = fixture.debugElement.query(By.css('select#year-select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + + describe('and selecting the first option in the month dropdown', () => { + let monthSelect; + + beforeEach(() => { + monthSelect = fixture.debugElement.query(By.css('select#month-select')).nativeElement; + monthSelect.value = monthSelect.options[0].value; + monthSelect.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('and selecting the second option in the month dropdown', () => { + let monthSelect; + + beforeEach(() => { + expectedValue = `${options[0]}-01`; + extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + monthSelect = fixture.debugElement.query(By.css('select#month-select')).nativeElement; + monthSelect.value = monthSelect.options[1].value; + monthSelect.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + }); + + describe('when filling in the input form', () => { + let form; + const expectedValue = '2015'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('form')); + comp.formData.value.startsWith = expectedValue; + form.triggerEventHandler('ngSubmit', null); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + }); + +}); diff --git a/src/app/shared/starts-with/date/starts-with-date.component.ts b/src/app/shared/starts-with/date/starts-with-date.component.ts new file mode 100644 index 0000000000..5f87ce8635 --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.ts @@ -0,0 +1,140 @@ +import { Component } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; +import { hasValue } from '../../empty.util'; + +/** + * A switchable component rendering StartsWith options for the type "Date". + * The options are rendered in a dropdown with an input field (of type number) next to it. + */ +@Component({ + selector: 'ds-starts-with-date', + styleUrls: ['./starts-with-date.component.scss'], + templateUrl: './starts-with-date.component.html' +}) +@renderStartsWithFor(StartsWithType.date) +export class StartsWithDateComponent extends StartsWithAbstractComponent { + + /** + * A list of options for months to select from + */ + monthOptions: string[]; + + /** + * Currently selected month + */ + startsWithMonth = 'none'; + + /** + * Currently selected year + */ + startsWithYear: number; + + ngOnInit() { + this.monthOptions = [ + 'none', + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december' + ]; + + super.ngOnInit(); + } + + /** + * Set the startsWith by event + * @param event + */ + setStartsWithYearEvent(event: Event) { + this.startsWithYear = +(event.target as HTMLInputElement).value; + this.setStartsWithYearMonth(); + this.setStartsWithParam(); + } + + /** + * Set the startsWithMonth by event + * @param event + */ + setStartsWithMonthEvent(event: Event) { + this.startsWithMonth = (event.target as HTMLInputElement).value; + this.setStartsWithYearMonth(); + this.setStartsWithParam(); + } + + /** + * Get startsWith year combined with month; + * Returned value: "{{year}}-{{month}}" + */ + getStartsWith() { + const month = this.getStartsWithMonth(); + if (month > 0 && hasValue(this.startsWithYear) && this.startsWithYear !== -1) { + let twoDigitMonth = '' + month; + if (month < 10) { + twoDigitMonth = `0${month}`; + } + return `${this.startsWithYear}-${twoDigitMonth}`; + } else { + if (hasValue(this.startsWithYear) && this.startsWithYear > 0) { + return '' + this.startsWithYear; + } else { + return undefined; + } + } + } + + /** + * Set startsWith year combined with month; + */ + setStartsWithYearMonth() { + this.startsWith = this.getStartsWith(); + } + + /** + * Set the startsWith by string + * This method also sets startsWithYear and startsWithMonth correctly depending on the received value + * - When startsWith contains a "-", the first part is considered the year, the second part the month + * - When startsWith doesn't contain a "-", the whole string is expected to be the year + * startsWithMonth will be set depending on the index received after the "-" + * @param startsWith + */ + setStartsWith(startsWith: string) { + this.startsWith = startsWith; + if (hasValue(startsWith) && startsWith.indexOf('-') > -1) { + const split = startsWith.split('-'); + this.startsWithYear = +split[0]; + const month = +split[1]; + if (month < this.monthOptions.length) { + this.startsWithMonth = this.monthOptions[month]; + } else { + this.startsWithMonth = this.monthOptions[0]; + } + } else { + this.startsWithYear = +startsWith; + } + this.setStartsWithParam(); + } + + /** + * Get startsWithYear as a number; + */ + getStartsWithYear() { + return this.startsWithYear; + } + + /** + * Get startsWithMonth as a number; + */ + getStartsWithMonth() { + return this.monthOptions.indexOf(this.startsWithMonth); + } + +} diff --git a/src/app/shared/starts-with/starts-with-abstract.component.ts b/src/app/shared/starts-with/starts-with-abstract.component.ts new file mode 100644 index 0000000000..967ec7a844 --- /dev/null +++ b/src/app/shared/starts-with/starts-with-abstract.component.ts @@ -0,0 +1,94 @@ +import { Inject, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { FormControl, FormGroup } from '@angular/forms'; +import { hasValue } from '../empty.util'; + +/** + * An abstract component to render StartsWith options + */ +export class StartsWithAbstractComponent implements OnInit, OnDestroy { + /** + * The currently selected startsWith in string format + */ + startsWith: string; + + /** + * The formdata controlling the StartsWith input + */ + formData: FormGroup; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + public constructor(@Inject('startsWithOptions') public startsWithOptions: any[], + protected route: ActivatedRoute, + protected router: Router) { + } + + ngOnInit(): void { + this.subs.push( + this.route.queryParams.subscribe((params) => { + if (hasValue(params.startsWith)) { + this.setStartsWith(params.startsWith); + } + }) + ); + this.formData = new FormGroup({ + startsWith: new FormControl() + }); + } + + /** + * Get startsWith + */ + getStartsWith(): any { + return this.startsWith; + } + + /** + * Set the startsWith by event + * @param event + */ + setStartsWithEvent(event: Event) { + this.startsWith = (event.target as HTMLInputElement).value; + this.setStartsWithParam(); + } + + /** + * Set the startsWith by string + * @param startsWith + */ + setStartsWith(startsWith: string) { + this.startsWith = startsWith; + this.setStartsWithParam(); + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '-1') { + this.startsWith = undefined; + } + this.router.navigate([], { + queryParams: Object.assign({ startsWith: this.startsWith }), + queryParamsHandling: 'merge' + }); + } + + /** + * Submit the form data. Called when clicking a submit button on the form. + * @param data + */ + submitForm(data) { + this.startsWith = data.startsWith; + this.setStartsWithParam(); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/shared/starts-with/starts-with-decorator.spec.ts b/src/app/shared/starts-with/starts-with-decorator.spec.ts new file mode 100644 index 0000000000..0ba72d8ac4 --- /dev/null +++ b/src/app/shared/starts-with/starts-with-decorator.spec.ts @@ -0,0 +1,13 @@ +import { renderStartsWithFor, StartsWithType } from './starts-with-decorator'; + +describe('BrowseByStartsWithDecorator', () => { + const textDecorator = renderStartsWithFor(StartsWithType.text); + const dateDecorator = renderStartsWithFor(StartsWithType.date); + it('should have a decorator for both text and date', () => { + expect(textDecorator.length).not.toBeNull(); + expect(dateDecorator.length).not.toBeNull(); + }); + it('should have 2 separate decorators for text and date', () => { + expect(textDecorator).not.toEqual(dateDecorator); + }); +}); diff --git a/src/app/shared/starts-with/starts-with-decorator.ts b/src/app/shared/starts-with/starts-with-decorator.ts new file mode 100644 index 0000000000..7592f00a8b --- /dev/null +++ b/src/app/shared/starts-with/starts-with-decorator.ts @@ -0,0 +1,30 @@ +const startsWithMap = new Map(); + +/** + * An enum that defines the type of StartsWith options + */ +export enum StartsWithType { + text = 'Text', + date = 'Date' +} + +/** + * Fetch a decorator to render a StartsWith component for type + * @param type + */ +export function renderStartsWithFor(type: StartsWithType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + startsWithMap.set(type, objectElement); + }; +} + +/** + * Get the correct component depending on the StartsWith type + * @param type + */ +export function getStartsWithComponent(type: StartsWithType) { + return startsWithMap.get(type); +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.html b/src/app/shared/starts-with/text/starts-with-text.component.html new file mode 100644 index 0000000000..dd7f4de278 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.html @@ -0,0 +1,29 @@ +
+
+
+ +
+
+ +
+
+ + + + +
+
+
diff --git a/src/app/shared/starts-with/text/starts-with-text.component.scss b/src/app/shared/starts-with/text/starts-with-text.component.scss new file mode 100644 index 0000000000..ceec56c8c2 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.spec.ts b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts new file mode 100644 index 0000000000..653c7e6196 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts @@ -0,0 +1,178 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../utils/enum-keys-pipe'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { StartsWithTextComponent } from './starts-with-text.component'; + +describe('StartsWithTextComponent', () => { + let comp: StartsWithTextComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let router: Router; + + const options = ['0-9', 'A', 'B', 'C']; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [StartsWithTextComponent, EnumKeysPipe], + providers: [ + { provide: 'startsWithOptions', useValue: options } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StartsWithTextComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + router = (comp as any).router; + spyOn(router, 'navigate'); + }); + + it('should create a FormGroup containing a startsWith FormControl', () => { + expect(comp.formData.value.startsWith).toBeDefined(); + }); + + describe('when selecting the first option in the dropdown', () => { + let select; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + select.value = select.options[0].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to undefined', () => { + expect(comp.startsWith).toBeUndefined(); + }); + + it('should not add a startsWith query parameter', () => { + route.queryParams.subscribe((params) => { + expect(params.startsWith).toBeUndefined(); + }); + }); + }); + + describe('when selecting "0-9" in the dropdown', () => { + let select; + let input; + const expectedValue = '0'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to "0"', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when selecting an option in the dropdown', () => { + let select; + let input; + const expectedValue = options[1]; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[2].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the expected value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when clicking an option in the list', () => { + let optionLink; + let input; + const expectedValue = options[1]; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + optionLink = fixture.debugElement.query(By.css('.list-inline-item:nth-child(2) > a')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + optionLink.click(); + fixture.detectChanges(); + }); + + it('should set startsWith to the expected value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when filling in the input form', () => { + let form; + const expectedValue = 'A'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('form')); + comp.formData.value.startsWith = expectedValue; + form.triggerEventHandler('ngSubmit', null); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + }); + +}); diff --git a/src/app/shared/starts-with/text/starts-with-text.component.ts b/src/app/shared/starts-with/text/starts-with-text.component.ts new file mode 100644 index 0000000000..ad89ce5c70 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.ts @@ -0,0 +1,49 @@ +import { Component, Inject } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; +import { hasValue } from '../../empty.util'; + +/** + * A switchable component rendering StartsWith options for the type "Text". + */ +@Component({ + selector: 'ds-starts-with-text', + styleUrls: ['./starts-with-text.component.scss'], + templateUrl: './starts-with-text.component.html' +}) +@renderStartsWithFor(StartsWithType.text) +export class StartsWithTextComponent extends StartsWithAbstractComponent { + + /** + * Get startsWith as text; + */ + getStartsWith() { + if (hasValue(this.startsWith)) { + return this.startsWith; + } else { + return ''; + } + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '0-9') { + this.startsWith = '0'; + } + super.setStartsWithParam(); + } + + /** + * Checks whether the provided option is equal to the current startsWith + * @param option + */ + isSelectedOption(option: string): boolean { + if (this.startsWith === '0' && option === '0-9') { + return true; + } + return option === this.startsWith; + } + +} diff --git a/src/config/browse-by-config.interface.ts b/src/config/browse-by-config.interface.ts new file mode 100644 index 0000000000..6adba66b92 --- /dev/null +++ b/src/config/browse-by-config.interface.ts @@ -0,0 +1,21 @@ +import { Config } from './config.interface'; + +/** + * Config that determines how the dropdown list of years are created for browse-by-date components + */ +export interface BrowseByConfig extends Config { + /** + * The max amount of years to display using jumps of one year (current year - oneYearLimit) + */ + oneYearLimit: number; + + /** + * Limit for years to display using jumps of five years (current year - fiveYearLimit) + */ + fiveYearLimit: number; + + /** + * The absolute lowest year to display in the dropdown when no lowest date can be found for all items + */ + defaultLowerLimit: number; +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index 59cc21a3b2..1388db49f6 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -5,6 +5,7 @@ import { UniversalConfig } from './universal-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; import { FormConfig } from './form-config.interfaces'; import {LangConfig} from './lang-config.interface'; +import { BrowseByConfig } from './browse-by-config.interface'; import { ItemPageConfig } from './item-page-config.interface'; export interface GlobalConfig extends Config { @@ -20,5 +21,6 @@ export interface GlobalConfig extends Config { debug: boolean; defaultLanguage: string; languages: LangConfig[]; + browseBy: BrowseByConfig; item: ItemPageConfig; }