diff --git a/src/app/browse-by/browse-by-guard.spec.ts b/src/app/browse-by/browse-by-guard.spec.ts index 933c95a3cb..7fca9b15c0 100644 --- a/src/app/browse-by/browse-by-guard.spec.ts +++ b/src/app/browse-by/browse-by-guard.spec.ts @@ -2,8 +2,8 @@ import { first } from 'rxjs/operators'; import { BrowseByGuard } from './browse-by-guard'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from './browse-by-switcher/browse-by-decorator'; +import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; describe('BrowseByGuard', () => { describe('canActivate', () => { @@ -18,7 +18,7 @@ describe('BrowseByGuard', () => { const id = 'author'; const scope = '1234-65487-12354-1235'; const value = 'Filter'; - const browseDefinition = Object.assign(new BrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); + const browseDefinition = Object.assign(new ValueListBrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); beforeEach(() => { dsoService = { diff --git a/src/app/browse-by/browse-by-routing.module.ts b/src/app/browse-by/browse-by-routing.module.ts index 65805f3559..bb67dc65ae 100644 --- a/src/app/browse-by/browse-by-routing.module.ts +++ b/src/app/browse-by/browse-by-routing.module.ts @@ -5,8 +5,6 @@ import { BrowseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolv import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver'; import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; -import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/browse-by-taxonomy-page.component'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; @NgModule({ imports: [ @@ -18,13 +16,6 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso menu: DSOEditMenuResolver }, children: [ - { - path: 'srsc', - component: BrowseByTaxonomyPageComponent, - canActivate: [BrowseByGuard], - resolve: { breadcrumb: I18nBreadcrumbResolver }, - data: { title: 'browse.title.page', breadcrumbKey: 'browse.metadata.srsc' } - }, { path: ':id', component: ThemedBrowseBySwitcherComponent, diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts index ceb4c6a6c6..b59a46cae1 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts @@ -26,7 +26,7 @@ const map = new Map(); * @param browseByType The type of page * @param theme The optional theme for the component */ -export function rendersBrowseBy(browseByType: BrowseByDataType, theme = DEFAULT_THEME) { +export function rendersBrowseBy(browseByType: string, theme = DEFAULT_THEME) { return function decorator(component: any) { if (hasNoValue(map.get(browseByType))) { map.set(browseByType, new Map()); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts index c2e1c9cb68..c13405dd4d 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts @@ -3,9 +3,11 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator'; -import { BrowseDefinition } from '../../core/shared/browse-definition.model'; import { BehaviorSubject } from 'rxjs'; import { ThemeService } from '../../shared/theme-support/theme.service'; +import { FlatBrowseDefinition } from '../../core/shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../../core/shared/value-list-browse-definition.model'; +import { NonHierarchicalBrowseDefinition } from '../../core/shared/non-hierarchical-browse-definition'; describe('BrowseBySwitcherComponent', () => { let comp: BrowseBySwitcherComponent; @@ -13,33 +15,33 @@ describe('BrowseBySwitcherComponent', () => { const types = [ Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'title', dataType: BrowseByDataType.Title, } ), Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'dateissued', dataType: BrowseByDataType.Date, metadataKeys: ['dc.date.issued'] } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'author', dataType: BrowseByDataType.Metadata, } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'subject', dataType: BrowseByDataType.Metadata, } ), ]; - const data = new BehaviorSubject(createDataWithBrowseDefinition(new BrowseDefinition())); + const data = new BehaviorSubject(createDataWithBrowseDefinition(new FlatBrowseDefinition())); const activatedRouteStub = { data @@ -70,7 +72,7 @@ describe('BrowseBySwitcherComponent', () => { comp = fixture.componentInstance; })); - types.forEach((type: BrowseDefinition) => { + types.forEach((type: NonHierarchicalBrowseDefinition) => { describe(`when switching to a browse-by page for "${type.id}"`, () => { beforeEach(() => { data.next(createDataWithBrowseDefinition(type)); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index 0d3a35bebf..35e4edf900 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -31,7 +31,7 @@ export class BrowseBySwitcherComponent implements OnInit { */ ngOnInit(): void { this.browseByComponent = this.route.data.pipe( - map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType, this.themeService.getThemeName())) + map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.getRenderType(), this.themeService.getThemeName())) ); } diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html index 149e1e6b33..87c7937b1b 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html @@ -6,5 +6,5 @@ (deselect)="onDeselect($event)"> - {{ 'browse.taxonomy.button' | translate }} + {{ 'browse.taxonomy.button' | translate }} diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts index bc9380d7ad..484992afbf 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts @@ -4,17 +4,36 @@ import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.compone import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; import { TranslateModule } from '@ngx-translate/core'; import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { createDataWithBrowseDefinition } from '../browse-by-switcher/browse-by-switcher.component.spec'; +import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; +import { ThemeService } from '../../shared/theme-support/theme.service'; describe('BrowseByTaxonomyPageComponent', () => { let component: BrowseByTaxonomyPageComponent; let fixture: ComponentFixture; + let themeService: ThemeService; let detail1: VocabularyEntryDetail; let detail2: VocabularyEntryDetail; + const data = new BehaviorSubject(createDataWithBrowseDefinition(new HierarchicalBrowseDefinition())); + const activatedRouteStub = { + data + }; + beforeEach(async () => { + themeService = jasmine.createSpyObj('themeService', { + getThemeName: 'dspace', + }); + await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot() ], declarations: [ BrowseByTaxonomyPageComponent ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: ThemeService, useValue: themeService }, + ], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts index b132f299d6..d568a97fd7 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts @@ -1,6 +1,14 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Inject, OnDestroy } from '@angular/core'; import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, Subscription } from 'rxjs'; +import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { BROWSE_BY_COMPONENT_FACTORY } from '../browse-by-switcher/browse-by-decorator'; +import { map } from 'rxjs/operators'; +import { ThemeService } from 'src/app/shared/theme-support/theme.service'; +import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; @Component({ selector: 'ds-browse-by-taxonomy-page', @@ -10,7 +18,7 @@ import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models /** * Component for browsing items by metadata in a hierarchical controlled vocabulary */ -export class BrowseByTaxonomyPageComponent implements OnInit { +export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { /** * The {@link VocabularyOptions} object @@ -27,8 +35,48 @@ export class BrowseByTaxonomyPageComponent implements OnInit { */ filterValues: string[]; - ngOnInit() { - this.vocabularyOptions = { name: 'srsc', closed: true }; + /** + * The facet the use when filtering + */ + facetType: string; + + /** + * The used vocabulary + */ + vocabularyName: string; + + /** + * The parameters used in the URL + */ + queryParams: any; + + /** + * Resolved browse-by component + */ + browseByComponent: Observable; + + /** + * Subscriptions to track + */ + browseByComponentSubs: Subscription[] = []; + + public constructor( protected route: ActivatedRoute, + protected themeService: ThemeService, + @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor) { + } + + ngOnInit(): void { + this.browseByComponent = this.route.data.pipe( + map((data: { browseDefinition: BrowseDefinition }) => { + this.getComponentByBrowseByType(data.browseDefinition.getRenderType(), this.themeService.getThemeName()); + return data.browseDefinition; + }) + ); + this.browseByComponentSubs.push(this.browseByComponent.subscribe((browseDefinition: HierarchicalBrowseDefinition) => { + this.facetType = browseDefinition.facetType; + this.vocabularyName = browseDefinition.vocabulary; + this.vocabularyOptions = { name: this.vocabularyName, closed: true }; + })); } /** @@ -41,10 +89,30 @@ export class BrowseByTaxonomyPageComponent implements OnInit { this.selectedItems.push(detail); this.filterValues = this.selectedItems .map((item: VocabularyEntryDetail) => `${item.value},equals`); + this.updateQueryParams(); } + /** + * Removes detail from selectedItems and filterValues. + * + * @param detail VocabularyEntryDetail to be removed + */ onDeselect(detail: VocabularyEntryDetail): void { this.selectedItems = this.selectedItems.filter((entry: VocabularyEntryDetail) => { return entry !== detail; }); this.filterValues = this.filterValues.filter((value: string) => { return value !== `${detail.value},equals`; }); + this.updateQueryParams(); + } + + /** + * Updates queryParams based on the current facetType and filterValues. + */ + private updateQueryParams(): void { + this.queryParams = { + ['f.' + this.facetType]: this.filterValues + }; + } + + ngOnDestroy(): void { + this.browseByComponentSubs.forEach((sub: Subscription) => sub.unsubscribe()); } } diff --git a/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts new file mode 100644 index 0000000000..212044b853 --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts @@ -0,0 +1,28 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.component'; + +@Component({ + selector: 'ds-themed-browse-by-taxonomy-page', + templateUrl: '../../shared/theme-support/themed.component.html', + styleUrls: [] +}) +/** + * Themed wrapper for BrowseByTaxonomyPageComponent + */ +@rendersBrowseBy('hierarchy') +export class ThemedBrowseByTaxonomyPageComponent extends ThemedComponent{ + + protected getComponentName(): string { + return 'BrowseByTaxonomyPageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-taxonomy-page.component`); + } +} diff --git a/src/app/browse-by/browse-by.module.ts b/src/app/browse-by/browse-by.module.ts index b5b088dead..c0e2d3f9ff 100644 --- a/src/app/browse-by/browse-by.module.ts +++ b/src/app/browse-by/browse-by.module.ts @@ -10,6 +10,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module'; import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component'; import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component'; import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component'; +import { ThemedBrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; import { FormModule } from '../shared/form/form.module'; @@ -19,11 +20,12 @@ const ENTRY_COMPONENTS = [ BrowseByTitlePageComponent, BrowseByMetadataPageComponent, BrowseByDatePageComponent, + BrowseByTaxonomyPageComponent, ThemedBrowseByMetadataPageComponent, ThemedBrowseByDatePageComponent, ThemedBrowseByTitlePageComponent, - + ThemedBrowseByTaxonomyPageComponent, ]; @NgModule({ @@ -37,7 +39,6 @@ const ENTRY_COMPONENTS = [ declarations: [ BrowseBySwitcherComponent, ThemedBrowseBySwitcherComponent, - BrowseByTaxonomyPageComponent, ...ENTRY_COMPONENTS ], exports: [ diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index 88d070000e..bc495a51f4 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,20 +1,60 @@ +// eslint-disable-next-line max-classes-per-file import { Injectable } from '@angular/core'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -import { BrowseDefinition } from '../shared/browse-definition.model'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list.model'; import { FindListOptions } from '../data/find-list-options.model'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; import { dataService } from '../data/base/data-service.decorator'; +import { isNotEmpty, isNotEmptyOperator, hasValue } from '../../shared/empty.util'; +import { take } from 'rxjs/operators'; +import { BrowseDefinitionRestRequest } from '../data/request.models'; import { RequestParam } from '../cache/models/request-param.model'; import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { BrowseDefinition } from '../shared/browse-definition.model'; + +/** + * Create a GET request for the given href, and send it. + * Use a GET request specific for BrowseDefinitions. + */ +export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestService, + responseMsToLive: number, + href$: string | Observable, + useCachedVersionIfAvailable: boolean = true): void => { + if (isNotEmpty(href$)) { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + href$.pipe( + isNotEmptyOperator(), + take(1) + ).subscribe((href: string) => { + const requestId = requestService.generateRequestId(); + const request = new BrowseDefinitionRestRequest(requestId, href); + if (hasValue(responseMsToLive)) { + request.responseMsToLive = responseMsToLive; + } + requestService.send(request, useCachedVersionIfAvailable); + }); + } +}; + +/** + * Custom extension of {@link FindAllDataImpl} to be able to send BrowseDefinitionRestRequests + */ +class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl { + createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable: boolean = true) { + createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable); + } +} /** * Data service responsible for retrieving browse definitions from the REST server @@ -24,7 +64,7 @@ import { SearchData, SearchDataImpl } from '../data/base/search-data'; }) @dataService(BROWSE_DEFINITION) export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData, SearchData { - private findAllData: FindAllDataImpl; + private findAllData: BrowseDefinitionFindAllDataImpl; private searchData: SearchDataImpl; constructor( @@ -35,7 +75,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService, useCachedVersionIfAvailable: boolean = true) { + createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable); + } } diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 46ac8c44f4..0e39e53e43 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -6,13 +6,15 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; -import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseService } from './browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList, getFirstUsedArgumentOfSpyMethod } from '../../shared/testing/utils.test'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; import { RequestEntry } from '../data/request-entry.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; describe('BrowseService', () => { let scheduler: TestScheduler; @@ -23,7 +25,7 @@ describe('BrowseService', () => { const browsesEndpointURL = 'https://rest.api/browses'; const halService: any = new HALEndpointServiceStub(browsesEndpointURL); const browseDefinitions = [ - Object.assign(new BrowseDefinition(), { + Object.assign(new FlatBrowseDefinition(), { id: 'date', metadataBrowse: false, sortOptions: [ @@ -50,7 +52,7 @@ describe('BrowseService', () => { items: { href: 'https://rest.api/discover/browses/dateissued/items' } } }), - Object.assign(new BrowseDefinition(), { + Object.assign(new ValueListBrowseDefinition(), { id: 'author', metadataBrowse: true, sortOptions: [ @@ -78,7 +80,23 @@ describe('BrowseService', () => { entries: { href: 'https://rest.api/discover/browses/author/entries' }, items: { href: 'https://rest.api/discover/browses/author/items' } } - }) + }), + Object.assign(new HierarchicalBrowseDefinition(), { + id: 'srsc', + browseType: 'hierarchicalBrowse', + facetType: 'subject', + vocabulary: 'srsc', + type: 'browse', + metadata: [ + 'dc.subject' + ], + _links: { + vocabulary: { 'href': 'https://rest.api/submission/vocabularies/srsc/' }, + items: { 'href': 'https://rest.api/discover/browses/srsc/items' }, + entries: { 'href': 'https://rest.api/discover/browses/srsc/entries' }, + self: { 'href': 'https://rest.api/discover/browses/srsc' } + } + }), ]; let browseDefinitionDataService; diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 989213a978..b210b34949 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -7,6 +7,7 @@ import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; @@ -240,7 +241,12 @@ export class BrowseService { getPaginatedListPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions .find((def: BrowseDefinition) => { - const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); + let matchingKeys = ''; + + if (Array.isArray((def as FlatBrowseDefinition).metadataKeys)) { + matchingKeys = (def as FlatBrowseDefinition).metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); + } + return isNotEmpty(matchingKeys); }) ), diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 319b42d58b..834c8c02a7 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -177,6 +177,10 @@ import { IdentifierData } from '../shared/object-list/identifier-data/identifier import { Subscription } from '../shared/subscriptions/models/subscription.model'; import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; import { ItemRequest } from './shared/item-request.model'; +import { HierarchicalBrowseDefinition } from './shared/hierarchical-browse-definition.model'; +import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; +import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -333,6 +337,10 @@ export const models = AuthStatus, BrowseEntry, BrowseDefinition, + NonHierarchicalBrowseDefinition, + FlatBrowseDefinition, + ValueListBrowseDefinition, + HierarchicalBrowseDefinition, ClaimedTask, TaskObject, PoolTask, diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts new file mode 100644 index 0000000000..9fa7239ef7 --- /dev/null +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -0,0 +1,64 @@ +import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; + +class TestService extends BrowseResponseParsingService { + constructor(protected objectCache: ObjectCacheService) { + super(objectCache); + } + + // Overwrite method to make it public for testing + public deserialize(obj): any { + return super.deserialize(obj); + } +} + +describe('BrowseResponseParsingService', () => { + let service: TestService; + + + beforeEach(() => { + service = new TestService(getMockObjectCacheService()); + }); + + describe('', () => { + const mockFlatBrowse = { + id: 'title', + browseType: 'flatBrowse', + type: 'browse', + }; + + const mockValueList = { + id: 'author', + browseType: 'valueList', + type: 'browse', + }; + + const mockHierarchicalBrowse = { + id: 'srsc', + browseType: 'hierarchicalBrowse', + type: 'browse', + }; + + it('should deserialize flatBrowses correctly', () => { + let deserialized = service.deserialize(mockFlatBrowse); + expect(deserialized.type).toBe(FLAT_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockFlatBrowse.id); + }); + + it('should deserialize valueList browses correctly', () => { + let deserialized = service.deserialize(mockValueList); + expect(deserialized.type).toBe(VALUE_LIST_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockValueList.id); + }); + + it('should deserialize hierarchicalBrowses correctly', () => { + let deserialized = service.deserialize(mockHierarchicalBrowse); + expect(deserialized.type).toBe(HIERARCHICAL_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockHierarchicalBrowse.id); + }); + }); +}); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts new file mode 100644 index 0000000000..a568cdb617 --- /dev/null +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { hasValue } from '../../shared/empty.util'; +import { + HIERARCHICAL_BROWSE_DEFINITION +} from '../shared/hierarchical-browse-definition.resource-type'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { Serializer } from '../serializer'; +import { BrowseDefinition } from '../shared/browse-definition.model'; +import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; +import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; +import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; + +/** + * A ResponseParsingService used to parse a REST API response to a BrowseDefinition object + */ +@Injectable({ + providedIn: 'root', +}) +export class BrowseResponseParsingService extends DspaceRestResponseParsingService { + constructor( + protected objectCache: ObjectCacheService, + ) { + super(objectCache); + } + + protected deserialize(obj): any { + const browseType: string = obj.browseType; + if (obj.type === BROWSE_DEFINITION.value && hasValue(browseType)) { + let serializer: Serializer; + if (browseType === HIERARCHICAL_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(HierarchicalBrowseDefinition); + } else if (browseType === FLAT_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(FlatBrowseDefinition); + } else if (browseType === VALUE_LIST_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(ValueListBrowseDefinition); + } else { + throw new Error('An error occurred while retrieving the browse definitions.'); + } + return serializer.deserialize(obj); + } else { + throw new Error('An error occurred while retrieving the browse definitions.'); + } + } +} diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index fd5a22fae9..74117e79d3 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -10,6 +10,10 @@ import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; import { RestRequest } from './rest-request.model'; +/** + * @deprecated use DspaceRestResponseParsingService for new code, this is only left to support a + * few legacy use cases, and should get removed eventually + */ @Injectable() export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected toCache = true; diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 6ab3f180d3..9809bc0fde 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -11,6 +11,7 @@ import { TaskResponseParsingService } from '../tasks/task-response-parsing.servi import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { RestRequestWithResponseParser } from './rest-request-with-response-parser.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { FindListOptions } from './find-list-options.model'; @@ -118,6 +119,15 @@ export class PatchRequest extends DSpaceRestRequest { } } +/** + * Class representing a BrowseDefinition HTTP Rest request object + */ +export class BrowseDefinitionRestRequest extends DSpaceRestRequest { + getResponseParser(): GenericConstructor { + return BrowseResponseParsingService; + } +} + export class FindListRequest extends GetRequest { constructor( uuid: string, diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index 863f454422..a5bed53c9f 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -1,50 +1,16 @@ -import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; -import { excludeFromEquals } from '../utilities/equals.decorators'; -import { BROWSE_DEFINITION } from './browse-definition.resource-type'; -import { HALLink } from './hal-link.model'; -import { ResourceType } from './resource-type'; -import { SortOption } from './sort-option.model'; +import { autoserialize } from 'cerialize'; import { CacheableObject } from '../cache/cacheable-object.model'; -import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator'; -@typedObject -export class BrowseDefinition extends CacheableObject { - static type = BROWSE_DEFINITION; - - /** - * The object type - */ - @excludeFromEquals - @autoserialize - type: ResourceType; +/** + * Base class for BrowseDefinition models + */ +export abstract class BrowseDefinition extends CacheableObject { @autoserialize id: string; - @autoserialize - metadataBrowse: boolean; - - @autoserialize - sortOptions: SortOption[]; - - @autoserializeAs('order') - defaultSortOrder: string; - - @autoserializeAs('metadata') - metadataKeys: string[]; - - @autoserialize - dataType: BrowseByDataType; - - get self(): string { - return this._links.self.href; - } - - @deserialize - _links: { - self: HALLink; - entries: HALLink; - items: HALLink; - }; + /** + * Get the render type of the BrowseDefinition model + */ + abstract getRenderType(): string; } diff --git a/src/app/core/shared/flat-browse-definition.model.ts b/src/app/core/shared/flat-browse-definition.model.ts new file mode 100644 index 0000000000..308f0c7a5b --- /dev/null +++ b/src/app/core/shared/flat-browse-definition.model.ts @@ -0,0 +1,29 @@ +import { inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { FLAT_BROWSE_DEFINITION } from './flat-browse-definition.resource-type'; +import { ResourceType } from './resource-type'; +import { NonHierarchicalBrowseDefinition } from './non-hierarchical-browse-definition'; + +/** + * BrowseDefinition model for browses of type 'flatBrowse' + */ +@typedObject +@inheritSerialization(NonHierarchicalBrowseDefinition) +export class FlatBrowseDefinition extends NonHierarchicalBrowseDefinition { + static type = FLAT_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = FLAT_BROWSE_DEFINITION; + + get self(): string { + return this._links.self.href; + } + + getRenderType(): string { + return this.dataType; + } +} diff --git a/src/app/core/shared/flat-browse-definition.resource-type.ts b/src/app/core/shared/flat-browse-definition.resource-type.ts new file mode 100644 index 0000000000..bfb01cd98c --- /dev/null +++ b/src/app/core/shared/flat-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for FlatBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const FLAT_BROWSE_DEFINITION = new ResourceType('flatBrowse'); diff --git a/src/app/core/shared/hierarchical-browse-definition.model.ts b/src/app/core/shared/hierarchical-browse-definition.model.ts new file mode 100644 index 0000000000..ca3ed7bff0 --- /dev/null +++ b/src/app/core/shared/hierarchical-browse-definition.model.ts @@ -0,0 +1,47 @@ +import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { HIERARCHICAL_BROWSE_DEFINITION } from './hierarchical-browse-definition.resource-type'; +import { HALLink } from './hal-link.model'; +import { ResourceType } from './resource-type'; +import { BrowseDefinition } from './browse-definition.model'; + +/** + * BrowseDefinition model for browses of type 'hierarchicalBrowse' + */ +@typedObject +@inheritSerialization(BrowseDefinition) +export class HierarchicalBrowseDefinition extends BrowseDefinition { + static type = HIERARCHICAL_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = HIERARCHICAL_BROWSE_DEFINITION; + + @autoserialize + facetType: string; + + @autoserialize + vocabulary: string; + + @autoserializeAs('metadata') + metadataKeys: string[]; + + get self(): string { + return this._links.self.href; + } + + @deserialize + _links: { + self: HALLink; + entries: HALLink; + items: HALLink; + vocabulary: HALLink; + }; + + getRenderType(): string { + return 'hierarchy'; + } +} diff --git a/src/app/core/shared/hierarchical-browse-definition.resource-type.ts b/src/app/core/shared/hierarchical-browse-definition.resource-type.ts new file mode 100644 index 0000000000..df06d67c7a --- /dev/null +++ b/src/app/core/shared/hierarchical-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for HierarchicalBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const HIERARCHICAL_BROWSE_DEFINITION = new ResourceType('hierarchicalBrowse'); diff --git a/src/app/core/shared/non-hierarchical-browse-definition.ts b/src/app/core/shared/non-hierarchical-browse-definition.ts new file mode 100644 index 0000000000..a4f6df43d9 --- /dev/null +++ b/src/app/core/shared/non-hierarchical-browse-definition.ts @@ -0,0 +1,32 @@ +import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; +import { SortOption } from './sort-option.model'; +import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator'; +import { HALLink } from './hal-link.model'; +import { BrowseDefinition } from './browse-definition.model'; + +/** + * Super class for NonHierarchicalBrowseDefinition models, + * e.g. FlatBrowseDefinition and ValueListBrowseDefinition + */ +@inheritSerialization(BrowseDefinition) +export abstract class NonHierarchicalBrowseDefinition extends BrowseDefinition { + + @autoserialize + sortOptions: SortOption[]; + + @autoserializeAs('order') + defaultSortOrder: string; + + @autoserializeAs('metadata') + metadataKeys: string[]; + + @autoserialize + dataType: BrowseByDataType; + + @deserialize + _links: { + self: HALLink; + entries: HALLink; + items: HALLink; + }; +} diff --git a/src/app/core/shared/value-list-browse-definition.model.ts b/src/app/core/shared/value-list-browse-definition.model.ts new file mode 100644 index 0000000000..33cce82cac --- /dev/null +++ b/src/app/core/shared/value-list-browse-definition.model.ts @@ -0,0 +1,29 @@ +import { inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { VALUE_LIST_BROWSE_DEFINITION } from './value-list-browse-definition.resource-type'; +import { ResourceType } from './resource-type'; +import { NonHierarchicalBrowseDefinition } from './non-hierarchical-browse-definition'; + +/** + * BrowseDefinition model for browses of type 'valueList' + */ +@typedObject +@inheritSerialization(NonHierarchicalBrowseDefinition) +export class ValueListBrowseDefinition extends NonHierarchicalBrowseDefinition { + static type = VALUE_LIST_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = VALUE_LIST_BROWSE_DEFINITION; + + get self(): string { + return this._links.self.href; + } + + getRenderType(): string { + return this.dataType; + } +} diff --git a/src/app/core/shared/value-list-browse-definition.resource-type.ts b/src/app/core/shared/value-list-browse-definition.resource-type.ts new file mode 100644 index 0000000000..8904dc472f --- /dev/null +++ b/src/app/core/shared/value-list-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ValueListBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const VALUE_LIST_BROWSE_DEFINITION = new ResourceType('valueList'); diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index c4819be903..3ec1df85fd 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -137,20 +137,6 @@ export class MenuResolver implements Resolve { } as TextMenuItemModel, } ); - menuList.push( - { - id: 'browse_global_by_srsc', - parentID: 'browse_global', - active: false, - visible: true, - index: 99, - model: { - type: MenuItemType.LINK, - text: `menu.section.browse_global_by_srsc`, - link: `/browse/srsc` - } as LinkMenuItemModel - } - ); } menuList.forEach((menuSection) => this.menuService.addSection(MenuID.PUBLIC, Object.assign(menuSection, { shouldPersistOnRouteChange: true diff --git a/src/app/navbar/navbar.component.spec.ts b/src/app/navbar/navbar.component.spec.ts index ada9be9d0b..983eace055 100644 --- a/src/app/navbar/navbar.component.spec.ts +++ b/src/app/navbar/navbar.component.spec.ts @@ -16,7 +16,6 @@ import { RouterTestingModule } from '@angular/router/testing'; import { BrowseService } from '../core/browse/browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { buildPaginatedList } from '../core/data/paginated-list.model'; -import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from '../browse-by/browse-by-switcher/browse-by-decorator'; import { Item } from '../core/shared/item.model'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; @@ -28,6 +27,9 @@ import { authReducer } from '../core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; import { EPersonMock } from '../shared/testing/eperson.mock'; +import { FlatBrowseDefinition } from '../core/shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; +import { HierarchicalBrowseDefinition } from '../core/shared/hierarchical-browse-definition.model'; let comp: NavbarComponent; let fixture: ComponentFixture; @@ -66,30 +68,35 @@ describe('NavbarComponent', () => { beforeEach(waitForAsync(() => { browseDefinitions = [ Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'title', dataType: BrowseByDataType.Title, } ), Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'dateissued', dataType: BrowseByDataType.Date, metadataKeys: ['dc.date.issued'] } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'author', dataType: BrowseByDataType.Metadata, } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'subject', dataType: BrowseByDataType.Metadata, } ), + Object.assign( + new HierarchicalBrowseDefinition(), { + id: 'srsc', + } + ), ]; initialState = { core: { diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts index 2199de920e..891a825745 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts @@ -248,7 +248,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit { } /** - * Method called on entry select + * Method called on entry select/deselect */ onSelect(item: VocabularyEntryDetail) { if (!this.selectedItems.includes(item.id)) { diff --git a/src/themes/custom/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html b/src/themes/custom/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss b/src/themes/custom/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts b/src/themes/custom/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts new file mode 100644 index 0000000000..34d80a0cb8 --- /dev/null +++ b/src/themes/custom/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { BrowseByTaxonomyPageComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component'; + +@Component({ + selector: 'ds-browse-by-taxonomy-page', + // templateUrl: './browse-by-taxonomy-page.component.html', + templateUrl: '../../../../../app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html', + // styleUrls: ['./browse-by-taxonomy-page.component.scss'], + styleUrls: ['../../../../../app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss'], +}) +/** + * Component for browsing items by metadata in a hierarchical controlled vocabulary + */ +export class BrowseByTaxonomyPageComponent extends BaseComponent { +} diff --git a/src/themes/custom/lazy-theme.module.ts b/src/themes/custom/lazy-theme.module.ts index b1290cc634..a79a063e68 100644 --- a/src/themes/custom/lazy-theme.module.ts +++ b/src/themes/custom/lazy-theme.module.ts @@ -114,6 +114,7 @@ import { ObjectListComponent } from './app/shared/object-list/object-list.compon import { BrowseByMetadataPageComponent } from './app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseByDatePageComponent } from './app/browse-by/browse-by-date-page/browse-by-date-page.component'; import { BrowseByTitlePageComponent } from './app/browse-by/browse-by-title-page/browse-by-title-page.component'; +import { BrowseByTaxonomyPageComponent } from './app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component'; import { ExternalSourceEntryImportModalComponent } from './app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component'; @@ -200,6 +201,7 @@ const DECLARATIONS = [ BrowseByMetadataPageComponent, BrowseByDatePageComponent, BrowseByTitlePageComponent, + BrowseByTaxonomyPageComponent, ExternalSourceEntryImportModalComponent, ResultsBackButtonComponent, DsoEditMetadataComponent,