diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts index 3a77149e5be..ed14096ce03 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; @@ -50,6 +50,8 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro */ subCollectionsRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); + subscriptions: Subscription[] = []; + constructor( protected cds: CollectionDataService, protected paginationService: PaginationService, @@ -77,7 +79,7 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); - observableCombineLatest([pagination$, sort$]).pipe( + this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe( switchMap(([currentPagination, currentSort]) => { return this.cds.findByParent(this.community.id, { currentPage: currentPagination.currentPage, @@ -87,11 +89,12 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro }) ).subscribe((results) => { this.subCollectionsRDObs.next(results); - }); + })); } ngOnDestroy(): void { - this.paginationService.clearPagination(this.config.id); + this.paginationService.clearPagination(this.config?.id); + this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe()); } } diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts index 5a0409a0519..08c9509edbd 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; @@ -52,6 +52,8 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy */ subCommunitiesRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); + subscriptions: Subscription[] = []; + constructor( protected cds: CommunityDataService, protected paginationService: PaginationService, @@ -79,7 +81,7 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); - observableCombineLatest([pagination$, sort$]).pipe( + this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe( switchMap(([currentPagination, currentSort]) => { return this.cds.findByParent(this.community.id, { currentPage: currentPagination.currentPage, @@ -89,11 +91,12 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy }) ).subscribe((results) => { this.subCommunitiesRDObs.next(results); - }); + })); } ngOnDestroy(): void { - this.paginationService.clearPagination(this.config.id); + this.paginationService.clearPagination(this.config?.id); + this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe()); } } diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index de044e25e33..557e13f57ba 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; diff --git a/src/app/curation-form/curation-form.component.spec.ts b/src/app/curation-form/curation-form.component.spec.ts index dc70b925e83..a0bdee21f49 100644 --- a/src/app/curation-form/curation-form.component.spec.ts +++ b/src/app/curation-form/curation-form.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync, fakeAsync, flush } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CurationFormComponent } from './curation-form.component'; @@ -16,6 +16,7 @@ import { ConfigurationDataService } from '../core/data/configuration-data.servic import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { getProcessDetailRoute } from '../process-page/process-page-routing.paths'; import { HandleService } from '../shared/handle.service'; +import { of as observableOf } from 'rxjs'; describe('CurationFormComponent', () => { let comp: CurationFormComponent; @@ -54,7 +55,7 @@ describe('CurationFormComponent', () => { }); handleService = { - normalizeHandle: (a) => a + normalizeHandle: (a: string) => observableOf(a), } as any; notificationsService = new NotificationsServiceStub(); @@ -151,12 +152,13 @@ describe('CurationFormComponent', () => { ], []); }); - it(`should show an error notification and return when an invalid dsoHandle is provided`, () => { + it(`should show an error notification and return when an invalid dsoHandle is provided`, fakeAsync(() => { comp.dsoHandle = 'test-handle'; - spyOn(handleService, 'normalizeHandle').and.returnValue(null); + spyOn(handleService, 'normalizeHandle').and.returnValue(observableOf(null)); comp.submit(); + flush(); expect(notificationsService.error).toHaveBeenCalled(); expect(scriptDataService.invoke).not.toHaveBeenCalled(); - }); + })); }); diff --git a/src/app/curation-form/curation-form.component.ts b/src/app/curation-form/curation-form.component.ts index 2c0e900a662..cc2c14f89fb 100644 --- a/src/app/curation-form/curation-form.component.ts +++ b/src/app/curation-form/curation-form.component.ts @@ -1,22 +1,22 @@ -import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ScriptDataService } from '../core/data/processes/script-data.service'; import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; -import { getFirstCompletedRemoteData } from '../core/shared/operators'; -import { find, map } from 'rxjs/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; +import { map } from 'rxjs/operators'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { hasValue, isEmpty, isNotEmpty } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; import { Router } from '@angular/router'; -import { ProcessDataService } from '../core/data/processes/process-data.service'; import { Process } from '../process-page/processes/process.model'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { ConfigurationProperty } from '../core/shared/configuration-property.model'; -import { Observable } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { getProcessDetailRoute } from '../process-page/process-page-routing.paths'; import { HandleService } from '../shared/handle.service'; export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask'; + /** * Component responsible for rendering the Curation Task form */ @@ -24,7 +24,7 @@ export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask'; selector: 'ds-curation-form', templateUrl: './curation-form.component.html' }) -export class CurationFormComponent implements OnInit { +export class CurationFormComponent implements OnDestroy, OnInit { config: Observable>; tasks: string[]; @@ -33,10 +33,11 @@ export class CurationFormComponent implements OnInit { @Input() dsoHandle: string; + subs: Subscription[] = []; + constructor( private scriptDataService: ScriptDataService, private configurationDataService: ConfigurationDataService, - private processDataService: ProcessDataService, private notificationsService: NotificationsService, private translateService: TranslateService, private handleService: HandleService, @@ -45,6 +46,10 @@ export class CurationFormComponent implements OnInit { ) { } + ngOnDestroy(): void { + this.subs.forEach((sub: Subscription) => sub.unsubscribe()); + } + ngOnInit(): void { this.form = new UntypedFormGroup({ task: new UntypedFormControl(''), @@ -52,16 +57,15 @@ export class CurationFormComponent implements OnInit { }); this.config = this.configurationDataService.findByPropertyName(CURATION_CFG); - this.config.pipe( - find((rd: RemoteData) => rd.hasSucceeded), - map((rd: RemoteData) => rd.payload) - ).subscribe((configProperties) => { + this.subs.push(this.config.pipe( + getFirstSucceededRemoteDataPayload(), + ).subscribe((configProperties: ConfigurationProperty) => { this.tasks = configProperties.values .filter((value) => isNotEmpty(value) && value.includes('=')) .map((value) => value.split('=')[1].trim()); this.form.get('task').patchValue(this.tasks[0]); this.cdr.detectChanges(); - }); + })); } /** @@ -77,33 +81,41 @@ export class CurationFormComponent implements OnInit { */ submit() { const taskName = this.form.get('task').value; - let handle; + let handle$: Observable; if (this.hasHandleValue()) { - handle = this.handleService.normalizeHandle(this.dsoHandle); - if (isEmpty(handle)) { - this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), - this.translateService.get('curation.form.submit.error.invalid-handle')); - return; - } + handle$ = this.handleService.normalizeHandle(this.dsoHandle).pipe( + map((handle: string | null) => { + if (isEmpty(handle)) { + this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), + this.translateService.get('curation.form.submit.error.invalid-handle')); + } + return handle; + }), + ); } else { - handle = this.handleService.normalizeHandle(this.form.get('handle').value); - if (isEmpty(handle)) { - handle = 'all'; - } + handle$ = this.handleService.normalizeHandle(this.form.get('handle').value).pipe( + map((handle: string | null) => isEmpty(handle) ? 'all' : handle), + ); } - this.scriptDataService.invoke('curate', [ - { name: '-t', value: taskName }, - { name: '-i', value: handle }, - ], []).pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { - if (rd.hasSucceeded) { - this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'), - this.translateService.get('curation.form.submit.success.content')); - this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); - } else { - this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), - this.translateService.get('curation.form.submit.error.content')); + this.subs.push(handle$.subscribe((handle: string) => { + if (hasValue(handle)) { + this.subs.push(this.scriptDataService.invoke('curate', [ + { name: '-t', value: taskName }, + { name: '-i', value: handle }, + ], []).pipe( + getFirstCompletedRemoteData(), + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'), + this.translateService.get('curation.form.submit.success.content')); + void this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } else { + this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), + this.translateService.get('curation.form.submit.error.content')); + } + })); } - }); + })); } } diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index aef43b510a8..160504f14fa 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -13,7 +13,7 @@ import { AuthService } from '../core/auth/auth.service'; import { CSSVariableService } from '../shared/sass-helper/css-variable.service'; import { MenuService } from '../shared/menu/menu.service'; import { HostWindowService } from '../shared/host-window.service'; -import { ThemeConfig } from '../../config/theme.model'; +import { ThemeConfig } from '../../config/theme.config'; import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; import { environment } from '../../environments/environment'; import { slideSidebarPadding } from '../shared/animations/slide'; diff --git a/src/app/shared/handle.service.spec.ts b/src/app/shared/handle.service.spec.ts index b326eb04163..8203940c6ad 100644 --- a/src/app/shared/handle.service.spec.ts +++ b/src/app/shared/handle.service.spec.ts @@ -1,47 +1,88 @@ -import { HandleService } from './handle.service'; +import { HandleService, CANONICAL_PREFIX_KEY } from './handle.service'; +import { TestBed } from '@angular/core/testing'; +import { ConfigurationDataServiceStub } from './testing/configuration-data.service.stub'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { createSuccessfulRemoteDataObject$ } from './remote-data.utils'; +import { ConfigurationProperty } from '../core/shared/configuration-property.model'; describe('HandleService', () => { let service: HandleService; + let configurationService: ConfigurationDataServiceStub; + beforeEach(() => { - service = new HandleService(); + configurationService = new ConfigurationDataServiceStub(); + + TestBed.configureTestingModule({ + providers: [ + { provide: ConfigurationDataService, useValue: configurationService }, + ], + }); + service = TestBed.inject(HandleService); }); describe(`normalizeHandle`, () => { - it(`should simply return an already normalized handle`, () => { - let input, output; - - input = '123456789/123456'; - output = service.normalizeHandle(input); - expect(output).toEqual(input); + it('should normalize a handle url with custom conical prefix with trailing slash', (done: DoneFn) => { + spyOn(configurationService, 'findByPropertyName').and.returnValue(createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: CANONICAL_PREFIX_KEY, + values: ['https://hdl.handle.net/'], + })); - input = '12.3456.789/123456'; - output = service.normalizeHandle(input); - expect(output).toEqual(input); + service.normalizeHandle('https://hdl.handle.net/123456789/123456').subscribe((handle: string | null) => { + expect(handle).toBe('123456789/123456'); + done(); + }); }); - it(`should normalize a handle url`, () => { - let input, output; + it('should normalize a handle url with custom conical prefix without trailing slash', (done: DoneFn) => { + spyOn(configurationService, 'findByPropertyName').and.returnValue(createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: CANONICAL_PREFIX_KEY, + values: ['https://hdl.handle.net/'], + })); - input = 'https://hdl.handle.net/handle/123456789/123456'; - output = service.normalizeHandle(input); - expect(output).toEqual('123456789/123456'); + service.normalizeHandle('https://hdl.handle.net/123456789/123456').subscribe((handle: string | null) => { + expect(handle).toBe('123456789/123456'); + done(); + }); + }); + + describe('should simply return an already normalized handle', () => { + it('123456789/123456', (done: DoneFn) => { + service.normalizeHandle('123456789/123456').subscribe((handle: string | null) => { + expect(handle).toBe('123456789/123456'); + done(); + }); + }); - input = 'https://rest.api/server/handle/123456789/123456'; - output = service.normalizeHandle(input); - expect(output).toEqual('123456789/123456'); + it('12.3456.789/123456', (done: DoneFn) => { + service.normalizeHandle('12.3456.789/123456').subscribe((handle: string | null) => { + expect(handle).toBe('12.3456.789/123456'); + done(); + }); + }); }); - it(`should return null if the input doesn't contain a handle`, () => { - let input, output; + it('should normalize handle urls starting with handle', (done: DoneFn) => { + service.normalizeHandle('https://rest.api/server/handle/123456789/123456').subscribe((handle: string | null) => { + expect(handle).toBe('123456789/123456'); + done(); + }); + }); - input = 'https://hdl.handle.net/handle/123456789'; - output = service.normalizeHandle(input); - expect(output).toBeNull(); + it('should return null if the input doesn\'t contain a valid handle', (done: DoneFn) => { + service.normalizeHandle('https://hdl.handle.net/123456789').subscribe((handle: string | null) => { + expect(handle).toBeNull(); + done(); + }); + }); - input = 'something completely different'; - output = service.normalizeHandle(input); - expect(output).toBeNull(); + it('should return null if the input doesn\'t contain a handle', (done: DoneFn) => { + service.normalizeHandle('something completely different').subscribe((handle: string | null) => { + expect(handle).toBeNull(); + done(); + }); }); }); }); diff --git a/src/app/shared/handle.service.ts b/src/app/shared/handle.service.ts index da0f17f7de3..1f22c7d3045 100644 --- a/src/app/shared/handle.service.ts +++ b/src/app/shared/handle.service.ts @@ -1,7 +1,18 @@ import { Injectable } from '@angular/core'; -import { isNotEmpty, isEmpty } from './empty.util'; +import { isEmpty, hasNoValue } from './empty.util'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { map, take } from 'rxjs/operators'; +import { ConfigurationProperty } from '../core/shared/configuration-property.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { RemoteData } from '../core/data/remote-data'; -const PREFIX_REGEX = /handle\/([^\/]+\/[^\/]+)$/; +export const CANONICAL_PREFIX_KEY = 'handle.canonical.prefix'; + +const PREFIX_REGEX = (prefix: string | undefined) => { + const formattedPrefix: string = prefix?.replace(/\/$/, ''); + return new RegExp(`(${formattedPrefix ? formattedPrefix + '|' : '' }handle)\/([^\/]+\/[^\/]+)$`); +}; const NO_PREFIX_REGEX = /^([^\/]+\/[^\/]+)$/; @Injectable({ @@ -9,33 +20,57 @@ const NO_PREFIX_REGEX = /^([^\/]+\/[^\/]+)$/; }) export class HandleService { + constructor( + protected configurationService: ConfigurationDataService, + ) { + } /** * Turns a handle string into the default 123456789/12345 format * - * @param handle the input handle + * When the handle.canonical.prefix doesn't end with handle, be sure to expose the variable so that the + * frontend can find the handle * - * normalizeHandle('123456789/123456') // '123456789/123456' - * normalizeHandle('12.3456.789/123456') // '12.3456.789/123456' - * normalizeHandle('https://hdl.handle.net/handle/123456789/123456') // '123456789/123456' - * normalizeHandle('https://rest.api/server/handle/123456789/123456') // '123456789/123456' - * normalizeHandle('https://rest.api/server/handle/123456789') // null + * @param handle the input handle + * @return + *
    + *
  • normalizeHandle('123456789/123456') // '123456789/123456'
  • + *
  • normalizeHandle('12.3456.789/123456') // '12.3456.789/123456'
  • + *
  • normalizeHandle('https://hdl.handle.net/123456789/123456') // '123456789/123456'
  • + *
  • normalizeHandle('https://rest.api/server/handle/123456789/123456') // '123456789/123456'
  • + *
  • normalizeHandle('https://rest.api/server/handle/123456789') // null
  • + *
*/ - normalizeHandle(handle: string): string { - let matches: string[]; - if (isNotEmpty(handle)) { - matches = handle.match(PREFIX_REGEX); + normalizeHandle(handle: string): Observable { + if (hasNoValue(handle)) { + return observableOf(null); } + return this.configurationService.findByPropertyName(CANONICAL_PREFIX_KEY).pipe( + getFirstCompletedRemoteData(), + map((configurationPropertyRD: RemoteData) => { + if (configurationPropertyRD.hasSucceeded) { + return configurationPropertyRD.payload.values.length >= 1 ? configurationPropertyRD.payload.values[0] : undefined; + } else { + return undefined; + } + }), + map((prefix: string | undefined) => { + let matches: string[]; - if (isEmpty(matches) || matches.length < 2) { - matches = handle.match(NO_PREFIX_REGEX); - } + matches = handle.match(PREFIX_REGEX(prefix)); - if (isEmpty(matches) || matches.length < 2) { - return null; - } else { - return matches[1]; - } + if (isEmpty(matches) || matches.length < 3) { + matches = handle.match(NO_PREFIX_REGEX); + } + + if (isEmpty(matches) || matches.length < 2) { + return null; + } else { + return matches[matches.length - 1]; + } + }), + take(1), + ); } } diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index e3c2960e51f..3997d175047 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -1,6 +1,6 @@ import { ThemeService } from '../theme-support/theme.service'; import { of as observableOf } from 'rxjs'; -import { ThemeConfig } from '../../../config/theme.model'; +import { ThemeConfig } from '../../../config/theme.config'; import { isNotEmpty } from '../empty.util'; export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService { diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts index e5654e63e00..470bcfcdafd 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts @@ -4,7 +4,7 @@ import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util'; import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { ListableObject } from '../listable-object.model'; import { environment } from '../../../../../environments/environment'; -import { ThemeConfig } from '../../../../../config/theme.model'; +import { ThemeConfig } from '../../../../../config/theme.config'; import { InjectionToken } from '@angular/core'; export const DEFAULT_VIEW_MODE = ViewMode.ListElement; diff --git a/src/app/shared/testing/configuration-data.service.stub.ts b/src/app/shared/testing/configuration-data.service.stub.ts new file mode 100644 index 00000000000..f17e2d7b5ba --- /dev/null +++ b/src/app/shared/testing/configuration-data.service.stub.ts @@ -0,0 +1,14 @@ +import { Observable } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; + +export class ConfigurationDataServiceStub { + + findByPropertyName(_name: string): Observable> { + const configurationProperty = new ConfigurationProperty(); + configurationProperty.values = []; + return createSuccessfulRemoteDataObject$(configurationProperty); + } + +} diff --git a/src/app/shared/theme-support/theme.model.ts b/src/app/shared/theme-support/theme.model.ts new file mode 100644 index 00000000000..470f09853c3 --- /dev/null +++ b/src/app/shared/theme-support/theme.model.ts @@ -0,0 +1,90 @@ +/* eslint-disable max-classes-per-file */ +import { hasValue, hasNoValue, isNotEmpty } from '../empty.util'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { getDSORoute } from '../../app-routing-paths'; +import { HandleObject } from '../../core/shared/handle-object.model'; +import { Injector } from '@angular/core'; +import { HandleService } from '../handle.service'; +import { combineLatest, Observable, of as observableOf } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { HandleThemeConfig, NamedThemeConfig, RegExThemeConfig, UUIDThemeConfig, ThemeConfig } from '../../../config/theme.config'; + +export class Theme { + constructor(public config: NamedThemeConfig) { + } + + matches(url: string, dso: DSpaceObject): Observable { + return observableOf(true); + } +} + +export class RegExTheme extends Theme { + regex: RegExp; + + constructor(public config: RegExThemeConfig) { + super(config); + this.regex = new RegExp(this.config.regex); + } + + matches(url: string, dso: DSpaceObject): Observable { + let match: RegExpMatchArray; + const route = getDSORoute(dso); + + if (isNotEmpty(route)) { + match = route.match(this.regex); + } + + if (hasNoValue(match)) { + match = url.match(this.regex); + } + + return observableOf(hasValue(match)); + } +} + +export class HandleTheme extends Theme { + + constructor(public config: HandleThemeConfig, + protected handleService: HandleService + ) { + super(config); + } + + matches(url: string, dso: T): Observable { + if (hasValue(dso?.handle)) { + return combineLatest([ + this.handleService.normalizeHandle(dso?.handle), + this.handleService.normalizeHandle(this.config.handle), + ]).pipe( + map(([handle, normalizedHandle]: [string | null, string | null]) => { + return hasValue(dso) && hasValue(dso.handle) && handle === normalizedHandle; + }), + take(1), + ); + } else { + return observableOf(false); + } + } +} + +export class UUIDTheme extends Theme { + constructor(public config: UUIDThemeConfig) { + super(config); + } + + matches(url: string, dso: DSpaceObject): Observable { + return observableOf(hasValue(dso) && dso.uuid === this.config.uuid); + } +} + +export const themeFactory = (config: ThemeConfig, injector: Injector): Theme => { + if (hasValue((config as RegExThemeConfig).regex)) { + return new RegExTheme(config as RegExThemeConfig); + } else if (hasValue((config as HandleThemeConfig).handle)) { + return new HandleTheme(config as HandleThemeConfig, injector.get(HandleService)); + } else if (hasValue((config as UUIDThemeConfig).uuid)) { + return new UUIDTheme(config as UUIDThemeConfig); + } else { + return new Theme(config as NamedThemeConfig); + } +}; diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts index f56fb86cbc1..34dffe2ef21 100644 --- a/src/app/shared/theme-support/theme.service.spec.ts +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -4,7 +4,7 @@ import { provideMockActions } from '@ngrx/effects/testing'; import { LinkService } from '../../core/cache/builders/link.service'; import { hot } from 'jasmine-marbles'; import { SetThemeAction } from './theme.actions'; -import { Theme } from '../../../config/theme.model'; +import { Theme } from './theme.model'; import { provideMockStore } from '@ngrx/store/testing'; import { Community } from '../../core/shared/community.model'; import { COMMUNITY } from '../../core/shared/community.resource-type'; @@ -24,6 +24,8 @@ import { ROUTER_NAVIGATED } from '@ngrx/router-store'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { CommonModule, DOCUMENT } from '@angular/common'; import { RouterMock } from '../mocks/router.mock'; +import { ConfigurationDataServiceStub } from '../testing/configuration-data.service.stub'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; /** * LinkService able to mock recursively resolving DSO parent links @@ -49,6 +51,7 @@ class MockLinkService { describe('ThemeService', () => { let themeService: ThemeService; let linkService: LinkService; + let configurationService: ConfigurationDataServiceStub; let initialState; let ancestorDSOs: DSpaceObject[]; @@ -78,6 +81,7 @@ describe('ThemeService', () => { currentTheme: 'custom', }, }; + configurationService = new ConfigurationDataServiceStub(); } function setupServiceWithActions(mockActions) { @@ -96,6 +100,7 @@ describe('ThemeService', () => { provideMockActions(() => mockActions), { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, + { provide: ConfigurationDataService, useValue: configurationService }, ] }); @@ -112,7 +117,7 @@ describe('ThemeService', () => { function spyOnPrivateMethods() { spyOn((themeService as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso])); - spyOn((themeService as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' })); + spyOn((themeService as any), 'matchThemeToDSOs').and.returnValue(observableOf(new Theme({ name: 'custom' }))); spyOn((themeService as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom')); } @@ -283,13 +288,13 @@ describe('ThemeService', () => { beforeEach(() => { nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), { - matches: () => false + matches: () => observableOf(false), }); itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), { - matches: (url, dso) => (dso as any).type === ITEM.value + matches: (url, dso) => observableOf((dso as any).type === ITEM.value), }); communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), { - matches: (url, dso) => (dso as any).type === COMMUNITY.value + matches: (url, dso) => observableOf((dso as any).type === COMMUNITY.value), }); dsos = [ Object.assign(new Item(), { @@ -313,8 +318,11 @@ describe('ThemeService', () => { themeService.themes = themes; }); - it('should return undefined', () => { - expect((themeService as any).matchThemeToDSOs(dsos, '')).toBeUndefined(); + it('should return undefined', (done: DoneFn) => { + (themeService as any).matchThemeToDSOs(dsos, '').subscribe((theme: Theme) => { + expect(theme).toBeUndefined(); + done(); + }); }); }); @@ -324,20 +332,31 @@ describe('ThemeService', () => { themeService.themes = themes; }); - it('should return the matching theme', () => { - expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + it('should return the matching theme', (done: DoneFn) => { + (themeService as any).matchThemeToDSOs(dsos, '').subscribe((theme: Theme) => { + expect(theme).toBe(itemMatchingTheme); + done(); + }); }); }); describe('when multiple themes match some of the DSOs', () => { - it('should return the first matching theme', () => { + it('should return the first matching theme (itemMatchingTheme)', (done: DoneFn) => { themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ]; themeService.themes = themes; - expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + (themeService as any).matchThemeToDSOs(dsos, '').subscribe((theme: Theme) => { + expect(theme).toBe(itemMatchingTheme); + done(); + }); + }); + it('should return the first matching theme (communityMatchingTheme)', (done: DoneFn) => { themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ]; themeService.themes = themes; - expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme); + (themeService as any).matchThemeToDSOs(dsos, '').subscribe((theme: Theme) => { + expect(theme).toBe(communityMatchingTheme); + done(); + }); }); }); }); @@ -382,6 +401,7 @@ describe('ThemeService', () => { const mockDsoService = { findById: () => createSuccessfulRemoteDataObject$(mockCommunity) }; + configurationService = new ConfigurationDataServiceStub(); TestBed.configureTestingModule({ imports: [ @@ -393,6 +413,7 @@ describe('ThemeService', () => { provideMockStore({ initialState }), { provide: DSpaceObjectDataService, useValue: mockDsoService }, { provide: Router, useValue: new RouterMock() }, + { provide: ConfigurationDataService, useValue: configurationService }, ] }); diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 4ce976dd58c..40aa559b23d 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -1,18 +1,15 @@ import { Injectable, Inject, Injector } from '@angular/core'; import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; -import { BehaviorSubject, EMPTY, Observable, of as observableOf } from 'rxjs'; +import { BehaviorSubject, EMPTY, Observable, of as observableOf, from, concatMap } from 'rxjs'; import { ThemeState } from './theme.reducer'; import { SetThemeAction, ThemeActionTypes } from './theme.actions'; -import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators'; +import { defaultIfEmpty, expand, filter, map, switchMap, take, toArray } from 'rxjs/operators'; import { hasNoValue, hasValue, isNotEmpty } from '../empty.util'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { - getFirstCompletedRemoteData, - getFirstSucceededRemoteData, - getRemoteDataPayload -} from '../../core/shared/operators'; -import { HeadTagConfig, Theme, ThemeConfig, themeFactory } from '../../../config/theme.model'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload } from '../../core/shared/operators'; +import { Theme, themeFactory } from './theme.model'; +import { ThemeConfig, HeadTagConfig } from '../../../config/theme.config'; import { NO_OP_ACTION_TYPE, NoOpAction } from '../ngrx/no-op.action'; import { followLink } from '../utils/follow-link-config.model'; import { LinkService } from '../../core/cache/builders/link.service'; @@ -219,7 +216,7 @@ export class ThemeService { // create new head tags (not yet added to DOM) const headTagFragment = this.document.createDocumentFragment(); this.createHeadTags(themeName) - .forEach(newHeadTag => headTagFragment.appendChild(newHeadTag)); + .forEach(newHeadTag => headTagFragment.appendChild(newHeadTag)); // add new head tags to DOM head.appendChild(headTagFragment); @@ -268,7 +265,7 @@ export class ThemeService { if (hasValue(headTagConfig.attributes)) { Object.entries(headTagConfig.attributes) - .forEach(([key, value]) => tag.setAttribute(key, value)); + .forEach(([key, value]) => tag.setAttribute(key, value)); } // 'class' attribute should always be 'theme-head-tag' for removal @@ -292,7 +289,7 @@ export class ThemeService { // and the current theme from the store const currentTheme$: Observable = this.store.pipe(select(currentThemeSelector)); - const action$ = currentTheme$.pipe( + const action$: Observable = currentTheme$.pipe( switchMap((currentTheme: string) => { const snapshotWithData = this.findRouteData(activatedRouteSnapshot); if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) { @@ -302,8 +299,10 @@ export class ThemeService { // Start with the resolved dso and go recursively through its parents until you reach the top-level community return observableOf(dsoRD.payload).pipe( this.getAncestorDSOs(), - map((dsos: DSpaceObject[]) => { - const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); + switchMap((dsos: DSpaceObject[]) => { + return this.matchThemeToDSOs(dsos, currentRouteUrl); + }), + map((dsoMatch: Theme) => { return this.getActionForMatch(dsoMatch, currentTheme); }) ); @@ -316,33 +315,41 @@ export class ThemeService { getFirstSucceededRemoteData(), getRemoteDataPayload(), this.getAncestorDSOs(), - map((dsos: DSpaceObject[]) => { - const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); + switchMap((dsos: DSpaceObject[]) => { + return this.matchThemeToDSOs(dsos, currentRouteUrl); + }), + map((dsoMatch: Theme) => { return this.getActionForMatch(dsoMatch, currentTheme); }) ); } // check whether the route itself matches - const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined)); - - return [this.getActionForMatch(routeMatch, currentTheme)]; + return from(this.themes).pipe( + concatMap((theme: Theme) => theme.matches(currentRouteUrl, undefined).pipe( + filter((result: boolean) => result === true), + map(() => theme), + take(1), + )), + take(1), + map((theme: Theme) => this.getActionForMatch(theme, currentTheme)) + ); + } else { + // If there are no themes configured, do nothing + return observableOf(new NoOpAction()); } - - // If there are no themes configured, do nothing - return [new NoOpAction()]; }), take(1), ); action$.pipe( - filter((action) => action.type !== NO_OP_ACTION_TYPE), - ).subscribe((action) => { + filter((action: SetThemeAction | NoOpAction) => action.type !== NO_OP_ACTION_TYPE), + ).subscribe((action: SetThemeAction | NoOpAction) => { this.store.dispatch(action); }); return action$.pipe( - map((action) => action.type === ThemeActionTypes.SET), + map((action: SetThemeAction | NoOpAction) => action.type === ThemeActionTypes.SET), ); } @@ -433,14 +440,17 @@ export class ThemeService { * @param currentRouteUrl The url for the current route * @private */ - private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme { - // iterate over the themes in order, and return the first one that matches - return this.themes.find((theme: Theme) => { - // iterate over the dsos's in order (most specific one first, so Item, Collection, - // Community), and return the first one that matches the current theme - const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso)); - return hasValue(match); - }); + private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Observable { + return from(this.themes).pipe( + concatMap((theme: Theme) => from(dsos).pipe( + concatMap((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso)), + filter((result: boolean) => result === true), + map(() => theme), + take(1), + )), + take(1), + defaultIfEmpty(undefined), + ); } /** diff --git a/src/app/shared/theme-support/themed.component.spec.ts b/src/app/shared/theme-support/themed.component.spec.ts index 7776e603798..3ddc13ab6c6 100644 --- a/src/app/shared/theme-support/themed.component.spec.ts +++ b/src/app/shared/theme-support/themed.component.spec.ts @@ -6,7 +6,7 @@ import { VarDirective } from '../utils/var.directive'; import { ThemeService } from './theme.service'; import { getMockThemeService } from '../mocks/theme-service.mock'; import { TestComponent } from './test/test.component.spec'; -import { ThemeConfig } from '../../../config/theme.model'; +import { ThemeConfig } from '../../../config/theme.config'; @Component({ selector: 'ds-test-themed-component', diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 84a30549a72..69aeab7cbf8 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -9,7 +9,7 @@ import { FormConfig } from './form-config.interfaces'; import { LangConfig } from './lang-config.interface'; import { ItemConfig } from './item-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; -import { ThemeConfig } from './theme.model'; +import { ThemeConfig } from './theme.config'; import { AuthConfig } from './auth-config.interfaces'; import { UIServerConfig } from './ui-server-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; diff --git a/src/config/config.util.spec.ts b/src/config/config.util.spec.ts index 2d1d8e1be79..4dc2b67260e 100644 --- a/src/config/config.util.spec.ts +++ b/src/config/config.util.spec.ts @@ -1,7 +1,7 @@ import { environment } from '../environments/environment.production'; import { extendEnvironmentWithAppConfig } from './config.util'; import { DefaultAppConfig } from './default-app-config'; -import { HandleThemeConfig } from './theme.model'; +import { HandleThemeConfig } from './theme.config'; describe('Config Util', () => { describe('extendEnvironmentWithAppConfig', () => { diff --git a/src/config/config.util.ts b/src/config/config.util.ts index 9912a817d6f..c1d87e34909 100644 --- a/src/config/config.util.ts +++ b/src/config/config.util.ts @@ -5,7 +5,7 @@ import { environment } from '../environments/environment'; import { hasNoValue } from '../app/shared/empty.util'; import { AppConfig } from './app-config.interface'; -import { ThemeConfig, NamedThemeConfig } from './theme.model'; +import { ThemeConfig, NamedThemeConfig } from './theme.config'; import { BASE_THEME_NAME } from '../app/shared/theme-support/theme.constants'; /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 61e7abcd62c..5eced97ee20 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -12,7 +12,7 @@ import { MediaViewerConfig } from './media-viewer-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; import { ServerConfig } from './server-config.interface'; import { SubmissionConfig } from './submission-config.interface'; -import { ThemeConfig } from './theme.model'; +import { ThemeConfig } from './theme.config'; import { UIServerConfig } from './ui-server-config.interface'; import { BundleConfig } from './bundle-config.interface'; import { ActuatorsConfig } from './actuators.config'; diff --git a/src/config/theme.config.ts b/src/config/theme.config.ts new file mode 100644 index 00000000000..7989dc3af9d --- /dev/null +++ b/src/config/theme.config.ts @@ -0,0 +1,51 @@ +import { Config } from './config.interface'; + +export interface NamedThemeConfig extends Config { + name: string; + + /** + * Specify another theme to build upon: whenever a themed component is not found in the current theme, + * its ancestor theme(s) will be checked recursively before falling back to the default theme. + */ + extends?: string; + + /** + * A list of HTML tags that should be added to the HEAD section of the document, whenever this theme is active. + */ + headTags?: HeadTagConfig[]; +} + +/** + * Interface that represents a single theme-specific HTML tag in the HEAD section of the page. + */ +export interface HeadTagConfig extends Config { + /** + * The name of the HTML tag + */ + tagName: string; + + /** + * The attributes on the HTML tag + */ + attributes?: { + [key: string]: string; + }; +} + +export interface RegExThemeConfig extends NamedThemeConfig { + regex: string; +} + +export interface HandleThemeConfig extends NamedThemeConfig { + handle: string; +} + +export interface UUIDThemeConfig extends NamedThemeConfig { + uuid: string; +} + +export type ThemeConfig + = NamedThemeConfig + | RegExThemeConfig + | HandleThemeConfig + | UUIDThemeConfig; diff --git a/src/config/theme.model.spec.ts b/src/config/theme.model.spec.ts index 79b5a1f32bf..de2e2f7cef6 100644 --- a/src/config/theme.model.spec.ts +++ b/src/config/theme.model.spec.ts @@ -1,4 +1,4 @@ -import { HandleTheme, RegExTheme, Theme, UUIDTheme } from './theme.model'; +import { HandleTheme, RegExTheme, Theme, UUIDTheme } from '../app/shared/theme-support/theme.model'; import { getCommunityModuleRoute } from '../app/community-page/community-page-routing-paths'; import { Community } from '../app/core/shared/community.model'; import { COMMUNITY } from '../app/core/shared/community.resource-type'; @@ -9,12 +9,15 @@ import { Item } from '../app/core/shared/item.model'; import { ITEM } from '../app/core/shared/item.resource-type'; import { getItemModuleRoute } from '../app/item-page/item-page-routing-paths'; import { HandleService } from '../app/shared/handle.service'; +import { TestBed } from '@angular/core/testing'; +import { ConfigurationDataService } from '../app/core/data/configuration-data.service'; +import { ConfigurationDataServiceStub } from '../app/shared/testing/configuration-data.service.stub'; describe('Theme Models', () => { let theme: Theme; describe('RegExTheme', () => { - it('should return true when the regex matches the community\'s DSO route', () => { + it('should return true when the regex matches the community\'s DSO route', (done: DoneFn) => { theme = new RegExTheme({ name: 'community', regex: getCommunityModuleRoute() + '/.*', @@ -23,10 +26,13 @@ describe('Theme Models', () => { type: COMMUNITY.value, uuid: 'community-uuid', }); - expect(theme.matches('', dso)).toEqual(true); + theme.matches('', dso).subscribe((matches: boolean) => { + expect(matches).toBeTrue(); + done(); + }); }); - it('should return true when the regex matches the collection\'s DSO route', () => { + it('should return true when the regex matches the collection\'s DSO route', (done: DoneFn) => { theme = new RegExTheme({ name: 'collection', regex: getCollectionModuleRoute() + '/.*', @@ -35,10 +41,13 @@ describe('Theme Models', () => { type: COLLECTION.value, uuid: 'collection-uuid', }); - expect(theme.matches('', dso)).toEqual(true); + theme.matches('', dso).subscribe((matches: boolean) => { + expect(matches).toBeTrue(); + done(); + }); }); - it('should return true when the regex matches the item\'s DSO route', () => { + it('should return true when the regex matches the item\'s DSO route', (done: DoneFn) => { theme = new RegExTheme({ name: 'item', regex: getItemModuleRoute() + '/.*', @@ -47,32 +56,51 @@ describe('Theme Models', () => { type: ITEM.value, uuid: 'item-uuid', }); - expect(theme.matches('', dso)).toEqual(true); + theme.matches('', dso).subscribe((matches: boolean) => { + expect(matches).toBeTrue(); + done(); + }); }); - it('should return true when the regex matches the url', () => { + it('should return true when the regex matches the url', (done: DoneFn) => { theme = new RegExTheme({ name: 'url', regex: '.*partial.*', }); - expect(theme.matches('theme/partial/url/match', null)).toEqual(true); + theme.matches('theme/partial/url/match', null).subscribe((matches: boolean) => { + expect(matches).toBeTrue(); + done(); + }); }); - it('should return false when the regex matches neither the url, nor the DSO route', () => { + it('should return false when the regex matches neither the url, nor the DSO route', (done: DoneFn) => { theme = new RegExTheme({ name: 'no-match', regex: '.*no/match.*', }); - expect(theme.matches('theme/partial/url/match', null)).toEqual(false); + theme.matches('theme/partial/url/match', null).subscribe((matches: boolean) => { + expect(matches).toBeFalse(); + done(); + }); }); }); describe('HandleTheme', () => { - let handleService; + let handleService: HandleService; + + let configurationService: ConfigurationDataServiceStub; + beforeEach(() => { - handleService = new HandleService(); + configurationService = new ConfigurationDataServiceStub(); + + TestBed.configureTestingModule({ + providers: [ + { provide: ConfigurationDataService, useValue: configurationService }, + ], }); - it('should return true when the DSO\'s handle matches the theme\'s handle', () => { + handleService = TestBed.inject(HandleService); + }); + it('should return true when the DSO\'s handle matches the theme\'s handle', (done: DoneFn) => { theme = new HandleTheme({ name: 'matching-handle', handle: '1234/5678', @@ -82,9 +110,12 @@ describe('Theme Models', () => { uuid: 'item-uuid', handle: '1234/5678', }, handleService); - expect(theme.matches('', matchingDso)).toEqual(true); + theme.matches('', matchingDso).subscribe((matches: boolean) => { + expect(matches).toBeTrue(); + done(); + }); }); - it('should return false when the DSO\'s handle contains the theme\'s handle as a subpart', () => { + it('should return false when the DSO\'s handle contains the theme\'s handle as a subpart', (done: DoneFn) => { theme = new HandleTheme({ name: 'matching-handle', handle: '1234/5678', @@ -94,10 +125,13 @@ describe('Theme Models', () => { uuid: 'item-uuid', handle: '1234/567891011', }); - expect(theme.matches('', dso)).toEqual(false); + theme.matches('', dso).subscribe((matches: boolean) => { + expect(matches).toBeFalse(); + done(); + }); }); - it('should return false when the handles don\'t match', () => { + it('should return false when the handles don\'t match', (done: DoneFn) => { theme = new HandleTheme({ name: 'no-matching-handle', handle: '1234/5678', @@ -107,12 +141,15 @@ describe('Theme Models', () => { uuid: 'item-uuid', handle: '1234/6789', }); - expect(theme.matches('', dso)).toEqual(false); + theme.matches('', dso).subscribe((matches: boolean) => { + expect(matches).toBeFalse(); + done(); + }); }); }); describe('UUIDTheme', () => { - it('should return true when the DSO\'s UUID matches the theme\'s UUID', () => { + it('should return true when the DSO\'s UUID matches the theme\'s UUID', (done: DoneFn) => { theme = new UUIDTheme({ name: 'matching-uuid', uuid: 'item-uuid', @@ -121,10 +158,13 @@ describe('Theme Models', () => { type: ITEM.value, uuid: 'item-uuid', }); - expect(theme.matches('', dso)).toEqual(true); + theme.matches('', dso).subscribe((matches: boolean) => { + expect(matches).toBeTrue(); + done(); + }); }); - it('should return true when the UUIDs don\'t match', () => { + it('should return true when the UUIDs don\'t match', (done: DoneFn) => { theme = new UUIDTheme({ name: 'matching-uuid', uuid: 'item-uuid', @@ -133,7 +173,10 @@ describe('Theme Models', () => { type: COLLECTION.value, uuid: 'collection-uuid', }); - expect(theme.matches('', dso)).toEqual(false); + theme.matches('', dso).subscribe((matches: boolean) => { + expect(matches).toBeFalse(); + done(); + }); }); }); }); diff --git a/src/config/theme.model.ts b/src/config/theme.model.ts deleted file mode 100644 index 019540f18a8..00000000000 --- a/src/config/theme.model.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* eslint-disable max-classes-per-file */ -import { Config } from './config.interface'; -import { hasValue, hasNoValue, isNotEmpty } from '../app/shared/empty.util'; -import { DSpaceObject } from '../app/core/shared/dspace-object.model'; -import { getDSORoute } from '../app/app-routing-paths'; -import { HandleObject } from '../app/core/shared/handle-object.model'; -import { Injector } from '@angular/core'; -import { HandleService } from '../app/shared/handle.service'; - -export interface NamedThemeConfig extends Config { - name: string; - - /** - * Specify another theme to build upon: whenever a themed component is not found in the current theme, - * its ancestor theme(s) will be checked recursively before falling back to the default theme. - */ - extends?: string; - - /** - * A list of HTML tags that should be added to the HEAD section of the document, whenever this theme is active. - */ - headTags?: HeadTagConfig[]; -} - -/** - * Interface that represents a single theme-specific HTML tag in the HEAD section of the page. - */ -export interface HeadTagConfig extends Config { - /** - * The name of the HTML tag - */ - tagName: string; - - /** - * The attributes on the HTML tag - */ - attributes?: { - [key: string]: string; - }; -} - -export interface RegExThemeConfig extends NamedThemeConfig { - regex: string; -} - -export interface HandleThemeConfig extends NamedThemeConfig { - handle: string; -} - -export interface UUIDThemeConfig extends NamedThemeConfig { - uuid: string; -} - -export class Theme { - constructor(public config: NamedThemeConfig) { - } - - matches(url: string, dso: DSpaceObject): boolean { - return true; - } -} - -export class RegExTheme extends Theme { - regex: RegExp; - - constructor(public config: RegExThemeConfig) { - super(config); - this.regex = new RegExp(this.config.regex); - } - - matches(url: string, dso: DSpaceObject): boolean { - let match; - const route = getDSORoute(dso); - - if (isNotEmpty(route)) { - match = route.match(this.regex); - } - - if (hasNoValue(match)) { - match = url.match(this.regex); - } - - return hasValue(match); - } -} - -export class HandleTheme extends Theme { - - private normalizedHandle; - - constructor(public config: HandleThemeConfig, - protected handleService: HandleService - ) { - super(config); - this.normalizedHandle = this.handleService.normalizeHandle(this.config.handle); - - } - - matches(url: string, dso: T): boolean { - return hasValue(dso) && hasValue(dso.handle) - && this.handleService.normalizeHandle(dso.handle) === this.normalizedHandle; - } -} - -export class UUIDTheme extends Theme { - constructor(public config: UUIDThemeConfig) { - super(config); - } - - matches(url: string, dso: DSpaceObject): boolean { - return hasValue(dso) && dso.uuid === this.config.uuid; - } -} - -export const themeFactory = (config: ThemeConfig, injector: Injector): Theme => { - if (hasValue((config as RegExThemeConfig).regex)) { - return new RegExTheme(config as RegExThemeConfig); - } else if (hasValue((config as HandleThemeConfig).handle)) { - return new HandleTheme(config as HandleThemeConfig, injector.get(HandleService)); - } else if (hasValue((config as UUIDThemeConfig).uuid)) { - return new UUIDTheme(config as UUIDThemeConfig); - } else { - return new Theme(config as NamedThemeConfig); - } -}; - -export type ThemeConfig - = NamedThemeConfig - | RegExThemeConfig - | HandleThemeConfig - | UUIDThemeConfig;