mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-12 20:43:08 +00:00
Merge branch 'main' into iiif-mirador
This commit is contained in:
14
README.md
14
README.md
@@ -212,13 +212,17 @@ Once you have tested the Pull Request, please add a comment and/or approval to t
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Unit tests use Karma. You can find the configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
|
||||
Unit tests use the [Jasmine test framework](https://jasmine.github.io/), and are run via [Karma](https://karma-runner.github.io/).
|
||||
|
||||
You can find the Karma configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
|
||||
|
||||
The default browser is Google Chrome.
|
||||
|
||||
Place your tests in the same location of the application source code files that they test.
|
||||
Place your tests in the same location of the application source code files that they test, e.g. ending with `*.component.spec.ts`
|
||||
|
||||
and run: `yarn run test`
|
||||
and run: `yarn test`
|
||||
|
||||
If you run into odd test errors, see the Angular guide to debugging tests: https://angular.io/guide/test-debugging
|
||||
|
||||
### E2E Tests
|
||||
|
||||
@@ -258,6 +262,10 @@ _Hint: Creating e2e tests is easiest in an IDE (like Visual Studio), as it can h
|
||||
|
||||
More Information: [docs.cypress.io](https://docs.cypress.io/) has great guides & documentation helping you learn more about writing/debugging e2e tests in Cypress.
|
||||
|
||||
### Learning how to build tests
|
||||
|
||||
See our [DSpace Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide) for more hints/tips.
|
||||
|
||||
Documentation
|
||||
--------------
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
|
||||
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
Optional,
|
||||
PLATFORM_ID,
|
||||
} from '@angular/core';
|
||||
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
NavigationCancel,
|
||||
NavigationEnd,
|
||||
NavigationStart, ResolveEnd,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
@@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
*/
|
||||
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/**
|
||||
* Whether or not the idle modal is is currently open
|
||||
@@ -105,7 +112,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
this.themeService.getThemeName$().subscribe((themeName: string) => {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
// the theme css will never download server side, so this should only happen on the browser
|
||||
this.isThemeLoading$.next(true);
|
||||
this.isThemeCSSLoading$.next(true);
|
||||
}
|
||||
if (hasValue(themeName)) {
|
||||
this.setThemeCss(themeName);
|
||||
@@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.router.events.pipe(
|
||||
// This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component
|
||||
// More information on this bug-fix: https://blog.angular-university.io/angular-debugging/
|
||||
delay(0)
|
||||
).subscribe((event) => {
|
||||
let resolveEndFound = false;
|
||||
this.router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
resolveEndFound = false;
|
||||
this.isRouteLoading$.next(true);
|
||||
this.isThemeLoading$.next(true);
|
||||
} else if (event instanceof ResolveEnd) {
|
||||
resolveEndFound = true;
|
||||
const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root;
|
||||
this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe(
|
||||
switchMap((changed) => {
|
||||
if (changed) {
|
||||
return this.isThemeCSSLoading$;
|
||||
} else {
|
||||
return [false];
|
||||
}
|
||||
})
|
||||
).subscribe((changed) => {
|
||||
this.isThemeLoading$.next(changed);
|
||||
});
|
||||
} else if (
|
||||
event instanceof NavigationEnd ||
|
||||
event instanceof NavigationCancel
|
||||
) {
|
||||
if (!resolveEndFound) {
|
||||
this.isThemeLoading$.next(false);
|
||||
}
|
||||
this.isRouteLoading$.next(false);
|
||||
}
|
||||
});
|
||||
@@ -237,7 +260,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
});
|
||||
}
|
||||
// the fact that this callback is used, proves we're on the browser.
|
||||
this.isThemeLoading$.next(false);
|
||||
this.isThemeCSSLoading$.next(false);
|
||||
};
|
||||
head.appendChild(link);
|
||||
}
|
||||
|
@@ -0,0 +1,54 @@
|
||||
<div *ngVar="(contentSource$ |async) as contentSource">
|
||||
<div class="container-fluid" *ngIf="shouldShow">
|
||||
<h4>{{ 'collection.source.controls.head' | translate }}</h4>
|
||||
<div>
|
||||
<span class="font-weight-bold">{{'collection.source.controls.harvest.status' | translate}}</span>
|
||||
<span>{{contentSource?.harvestStatus}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-weight-bold">{{'collection.source.controls.harvest.start' | translate}}</span>
|
||||
<span>{{contentSource?.harvestStartTime ? contentSource?.harvestStartTime : 'collection.source.controls.harvest.no-information'|translate }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-weight-bold">{{'collection.source.controls.harvest.last' | translate}}</span>
|
||||
<span>{{contentSource?.message ? contentSource?.message : 'collection.source.controls.harvest.no-information'|translate }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-weight-bold">{{'collection.source.controls.harvest.message' | translate}}</span>
|
||||
<span>{{contentSource?.lastHarvested ? contentSource?.lastHarvested : 'collection.source.controls.harvest.no-information'|translate }}</span>
|
||||
</div>
|
||||
|
||||
<button *ngIf="!(testConfigRunning$ |async)" class="btn btn-secondary"
|
||||
[disabled]="!(isEnabled)"
|
||||
(click)="testConfiguration(contentSource)">
|
||||
<span>{{'collection.source.controls.test.submit' | translate}}</span>
|
||||
</button>
|
||||
<button *ngIf="(testConfigRunning$ |async)" class="btn btn-secondary"
|
||||
[disabled]="true">
|
||||
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
|
||||
<span>{{'collection.source.controls.test.running' | translate}}</span>
|
||||
</button>
|
||||
<button *ngIf="!(importRunning$ |async)" class="btn btn-primary"
|
||||
[disabled]="!(isEnabled)"
|
||||
(click)="importNow()">
|
||||
<span class="d-none d-sm-inline">{{'collection.source.controls.import.submit' | translate}}</span>
|
||||
</button>
|
||||
<button *ngIf="(importRunning$ |async)" class="btn btn-primary"
|
||||
[disabled]="true">
|
||||
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
|
||||
<span class="d-none d-sm-inline">{{'collection.source.controls.import.running' | translate}}</span>
|
||||
</button>
|
||||
<button *ngIf="!(reImportRunning$ |async)" class="btn btn-primary"
|
||||
[disabled]="!(isEnabled)"
|
||||
(click)="resetAndReimport()">
|
||||
<span class="d-none d-sm-inline"> {{'collection.source.controls.reset.submit' | translate}}</span>
|
||||
</button>
|
||||
<button *ngIf="(reImportRunning$ |async)" class="btn btn-primary"
|
||||
[disabled]="true">
|
||||
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
|
||||
<span class="d-none d-sm-inline"> {{'collection.source.controls.reset.running' | translate}}</span>
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,3 @@
|
||||
.spinner-button {
|
||||
margin-bottom: calc((var(--bs-line-height-base) * 1rem - var(--bs-font-size-base)) / 2);
|
||||
}
|
@@ -0,0 +1,232 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { ContentSource } from '../../../../core/shared/content-source.model';
|
||||
import { Collection } from '../../../../core/shared/collection.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { CollectionDataService } from '../../../../core/data/collection-data.service';
|
||||
import { RequestService } from '../../../../core/data/request.service';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
|
||||
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
|
||||
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
|
||||
import { Process } from '../../../../process-page/processes/process.model';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { CollectionSourceControlsComponent } from './collection-source-controls.component';
|
||||
import { Bitstream } from '../../../../core/shared/bitstream.model';
|
||||
import { getTestScheduler } from 'jasmine-marbles';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { VarDirective } from '../../../../shared/utils/var.directive';
|
||||
import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer';
|
||||
|
||||
describe('CollectionSourceControlsComponent', () => {
|
||||
let comp: CollectionSourceControlsComponent;
|
||||
let fixture: ComponentFixture<CollectionSourceControlsComponent>;
|
||||
|
||||
const uuid = '29481ed7-ae6b-409a-8c51-34dd347a0ce4';
|
||||
let contentSource: ContentSource;
|
||||
let collection: Collection;
|
||||
let process: Process;
|
||||
let bitstream: Bitstream;
|
||||
|
||||
let scriptDataService: ScriptDataService;
|
||||
let processDataService: ProcessDataService;
|
||||
let requestService: RequestService;
|
||||
let notificationsService;
|
||||
let collectionService: CollectionDataService;
|
||||
let httpClient: HttpClient;
|
||||
let bitstreamService: BitstreamDataService;
|
||||
let scheduler: TestScheduler;
|
||||
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
scheduler = getTestScheduler();
|
||||
contentSource = Object.assign(new ContentSource(), {
|
||||
uuid: uuid,
|
||||
metadataConfigs: [
|
||||
{
|
||||
id: 'dc',
|
||||
label: 'Simple Dublin Core',
|
||||
nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/'
|
||||
},
|
||||
{
|
||||
id: 'qdc',
|
||||
label: 'Qualified Dublin Core',
|
||||
nameSpace: 'http://purl.org/dc/terms/'
|
||||
},
|
||||
{
|
||||
id: 'dim',
|
||||
label: 'DSpace Intermediate Metadata',
|
||||
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
||||
}
|
||||
],
|
||||
oaiSource: 'oai-harvest-source',
|
||||
oaiSetId: 'oai-set-id',
|
||||
_links: {self: {href: 'contentsource-selflink'}}
|
||||
});
|
||||
process = Object.assign(new Process(), {
|
||||
processId: 'process-id', processStatus: 'COMPLETED',
|
||||
_links: {output: {href: 'output-href'}}
|
||||
});
|
||||
|
||||
bitstream = Object.assign(new Bitstream(), {_links: {content: {href: 'content-href'}}});
|
||||
|
||||
collection = Object.assign(new Collection(), {
|
||||
uuid: 'fake-collection-id',
|
||||
_links: {self: {href: 'collection-selflink'}}
|
||||
});
|
||||
notificationsService = new NotificationsServiceStub();
|
||||
collectionService = jasmine.createSpyObj('collectionService', {
|
||||
getContentSource: createSuccessfulRemoteDataObject$(contentSource),
|
||||
findByHref: createSuccessfulRemoteDataObject$(collection)
|
||||
});
|
||||
scriptDataService = jasmine.createSpyObj('scriptDataService', {
|
||||
invoke: createSuccessfulRemoteDataObject$(process),
|
||||
});
|
||||
processDataService = jasmine.createSpyObj('processDataService', {
|
||||
findById: createSuccessfulRemoteDataObject$(process),
|
||||
});
|
||||
bitstreamService = jasmine.createSpyObj('bitstreamService', {
|
||||
findByHref: createSuccessfulRemoteDataObject$(bitstream),
|
||||
});
|
||||
httpClient = jasmine.createSpyObj('httpClient', {
|
||||
get: observableOf('Script text'),
|
||||
});
|
||||
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||
declarations: [CollectionSourceControlsComponent, VarDirective],
|
||||
providers: [
|
||||
{provide: ScriptDataService, useValue: scriptDataService},
|
||||
{provide: ProcessDataService, useValue: processDataService},
|
||||
{provide: RequestService, useValue: requestService},
|
||||
{provide: NotificationsService, useValue: notificationsService},
|
||||
{provide: CollectionDataService, useValue: collectionService},
|
||||
{provide: HttpClient, useValue: httpClient},
|
||||
{provide: BitstreamDataService, useValue: bitstreamService}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CollectionSourceControlsComponent);
|
||||
comp = fixture.componentInstance;
|
||||
comp.isEnabled = true;
|
||||
comp.collection = collection;
|
||||
comp.shouldShow = true;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
describe('init', () => {
|
||||
it('should', () => {
|
||||
expect(comp).toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe('testConfiguration', () => {
|
||||
it('should invoke a script and ping the resulting process until completed and show the resulting info', () => {
|
||||
comp.testConfiguration(contentSource);
|
||||
scheduler.flush();
|
||||
|
||||
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
|
||||
{name: '-g', value: null},
|
||||
{name: '-a', value: contentSource.oaiSource},
|
||||
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
|
||||
], []);
|
||||
|
||||
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||
expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href);
|
||||
expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text');
|
||||
});
|
||||
});
|
||||
describe('importNow', () => {
|
||||
it('should invoke a script that will start the harvest', () => {
|
||||
comp.importNow();
|
||||
scheduler.flush();
|
||||
|
||||
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
|
||||
{name: '-r', value: null},
|
||||
{name: '-c', value: collection.uuid},
|
||||
], []);
|
||||
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('resetAndReimport', () => {
|
||||
it('should invoke a script that will start the harvest', () => {
|
||||
comp.resetAndReimport();
|
||||
scheduler.flush();
|
||||
|
||||
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
|
||||
{name: '-o', value: null},
|
||||
{name: '-c', value: collection.uuid},
|
||||
], []);
|
||||
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('the controls', () => {
|
||||
it('should be shown when shouldShow is true', () => {
|
||||
comp.shouldShow = true;
|
||||
fixture.detectChanges();
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
expect(buttons.length).toEqual(3);
|
||||
});
|
||||
it('should be shown when shouldShow is false', () => {
|
||||
comp.shouldShow = false;
|
||||
fixture.detectChanges();
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
expect(buttons.length).toEqual(0);
|
||||
});
|
||||
it('should be disabled when isEnabled is false', () => {
|
||||
comp.shouldShow = true;
|
||||
comp.isEnabled = false;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
|
||||
expect(buttons[0].nativeElement.disabled).toBeTrue();
|
||||
expect(buttons[1].nativeElement.disabled).toBeTrue();
|
||||
expect(buttons[2].nativeElement.disabled).toBeTrue();
|
||||
});
|
||||
it('should be enabled when isEnabled is true', () => {
|
||||
comp.shouldShow = true;
|
||||
comp.isEnabled = true;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
|
||||
expect(buttons[0].nativeElement.disabled).toBeFalse();
|
||||
expect(buttons[1].nativeElement.disabled).toBeFalse();
|
||||
expect(buttons[2].nativeElement.disabled).toBeFalse();
|
||||
});
|
||||
it('should call the corresponding button when clicked', () => {
|
||||
spyOn(comp, 'testConfiguration');
|
||||
spyOn(comp, 'importNow');
|
||||
spyOn(comp, 'resetAndReimport');
|
||||
|
||||
comp.shouldShow = true;
|
||||
comp.isEnabled = true;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
|
||||
buttons[0].triggerEventHandler('click', null);
|
||||
expect(comp.testConfiguration).toHaveBeenCalled();
|
||||
|
||||
buttons[1].triggerEventHandler('click', null);
|
||||
expect(comp.importNow).toHaveBeenCalled();
|
||||
|
||||
buttons[2].triggerEventHandler('click', null);
|
||||
expect(comp.resetAndReimport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
@@ -0,0 +1,233 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
|
||||
import { ContentSource } from '../../../../core/shared/content-source.model';
|
||||
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
|
||||
import {
|
||||
getAllCompletedRemoteData,
|
||||
getAllSucceededRemoteDataPayload,
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteDataPayload
|
||||
} from '../../../../core/shared/operators';
|
||||
import { filter, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { hasValue, hasValueOperator } from '../../../../shared/empty.util';
|
||||
import { ProcessStatus } from '../../../../process-page/processes/process-status.model';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { RequestService } from '../../../../core/data/request.service';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { Collection } from '../../../../core/shared/collection.model';
|
||||
import { CollectionDataService } from '../../../../core/data/collection-data.service';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Process } from '../../../../process-page/processes/process.model';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
|
||||
import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer';
|
||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||
|
||||
/**
|
||||
* Component that contains the controls to run, reset and test the harvest
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-collection-source-controls',
|
||||
styleUrls: ['./collection-source-controls.component.scss'],
|
||||
templateUrl: './collection-source-controls.component.html',
|
||||
})
|
||||
export class CollectionSourceControlsComponent implements OnDestroy {
|
||||
|
||||
/**
|
||||
* Should the controls be enabled.
|
||||
*/
|
||||
@Input() isEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The current collection
|
||||
*/
|
||||
@Input() collection: Collection;
|
||||
|
||||
/**
|
||||
* Should the control section be shown
|
||||
*/
|
||||
@Input() shouldShow: boolean;
|
||||
|
||||
contentSource$: Observable<ContentSource>;
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
testConfigRunning$ = new BehaviorSubject(false);
|
||||
importRunning$ = new BehaviorSubject(false);
|
||||
reImportRunning$ = new BehaviorSubject(false);
|
||||
|
||||
constructor(private scriptDataService: ScriptDataService,
|
||||
private processDataService: ProcessDataService,
|
||||
private requestService: RequestService,
|
||||
private notificationsService: NotificationsService,
|
||||
private collectionService: CollectionDataService,
|
||||
private translateService: TranslateService,
|
||||
private httpClient: HttpClient,
|
||||
private bitstreamService: BitstreamDataService
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// ensure the contentSource gets updated after being set to stale
|
||||
this.contentSource$ = this.collectionService.findByHref(this.collection._links.self.href, false).pipe(
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
switchMap((collection) => this.collectionService.getContentSource(collection.uuid, false)),
|
||||
getAllSucceededRemoteDataPayload()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the provided content source's configuration.
|
||||
* @param contentSource - The content source to be tested
|
||||
*/
|
||||
testConfiguration(contentSource) {
|
||||
this.testConfigRunning$.next(true);
|
||||
this.subs.push(this.scriptDataService.invoke('harvest', [
|
||||
{name: '-g', value: null},
|
||||
{name: '-a', value: contentSource.oaiSource},
|
||||
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
|
||||
], []).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
tap((rd) => {
|
||||
if (rd.hasFailed) {
|
||||
// show a notification when the script invocation fails
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.test.submit.error'));
|
||||
this.testConfigRunning$.next(false);
|
||||
}
|
||||
}),
|
||||
// filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful.
|
||||
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||
getAllCompletedRemoteData(),
|
||||
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||
map((rd) => rd.payload),
|
||||
hasValueOperator(),
|
||||
).subscribe((process: Process) => {
|
||||
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
// Ping the current process state every 5s
|
||||
setTimeout(() => {
|
||||
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||
}, 5000);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed'));
|
||||
this.testConfigRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => {
|
||||
this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => {
|
||||
const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1')
|
||||
.replaceAll('The script has started', '')
|
||||
.replaceAll('The script has completed', '');
|
||||
this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output);
|
||||
});
|
||||
});
|
||||
this.testConfigRunning$.next(false);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the harvest for the current collection
|
||||
*/
|
||||
importNow() {
|
||||
this.importRunning$.next(true);
|
||||
this.subs.push(this.scriptDataService.invoke('harvest', [
|
||||
{name: '-r', value: null},
|
||||
{name: '-c', value: this.collection.uuid},
|
||||
], [])
|
||||
.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
tap((rd) => {
|
||||
if (rd.hasFailed) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.import.submit.error'));
|
||||
this.importRunning$.next(false);
|
||||
} else {
|
||||
this.notificationsService.success(this.translateService.get('collection.source.controls.import.submit.success'));
|
||||
}
|
||||
}),
|
||||
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||
getAllCompletedRemoteData(),
|
||||
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||
map((rd) => rd.payload),
|
||||
hasValueOperator(),
|
||||
).subscribe((process) => {
|
||||
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
// Ping the current process state every 5s
|
||||
setTimeout(() => {
|
||||
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
}, 5000);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed'));
|
||||
this.importRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed'));
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
this.importRunning$.next(false);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset and reimport the current collection
|
||||
*/
|
||||
resetAndReimport() {
|
||||
this.reImportRunning$.next(true);
|
||||
this.subs.push(this.scriptDataService.invoke('harvest', [
|
||||
{name: '-o', value: null},
|
||||
{name: '-c', value: this.collection.uuid},
|
||||
], [])
|
||||
.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
tap((rd) => {
|
||||
if (rd.hasFailed) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.submit.error'));
|
||||
this.reImportRunning$.next(false);
|
||||
} else {
|
||||
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.submit.success'));
|
||||
}
|
||||
}),
|
||||
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||
getAllCompletedRemoteData(),
|
||||
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||
map((rd) => rd.payload),
|
||||
hasValueOperator(),
|
||||
).subscribe((process) => {
|
||||
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
// Ping the current process state every 5s
|
||||
setTimeout(() => {
|
||||
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
}, 5000);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed'));
|
||||
this.reImportRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed'));
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
this.reImportRunning$.next(false);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach((sub) => {
|
||||
if (hasValue(sub)) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -11,7 +11,8 @@
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||
<button class="btn btn-primary"
|
||||
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||
(click)="onSubmit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
@@ -19,12 +20,15 @@
|
||||
</div>
|
||||
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
||||
<div *ngIf="contentSource" class="form-check mb-4">
|
||||
<input type="checkbox" class="form-check-input" id="externalSourceCheck" [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
||||
<label class="form-check-label" for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
||||
<input type="checkbox" class="form-check-input" id="externalSourceCheck"
|
||||
[checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
||||
<label class="form-check-label"
|
||||
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
||||
</div>
|
||||
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
|
||||
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
|
||||
</div>
|
||||
<div class="row">
|
||||
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
||||
[formId]="'collection-source-form-id'"
|
||||
[formGroup]="formGroup"
|
||||
@@ -35,8 +39,11 @@
|
||||
(dfChange)="onChange($event)"
|
||||
(submitForm)="onSubmit()"
|
||||
(cancel)="onCancel()"></ds-form>
|
||||
<div class="container-fluid" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
|
||||
<div class="d-inline-block float-right">
|
||||
</div>
|
||||
<div class="container mt-2" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-inline-block float-right ml-1">
|
||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
@@ -48,10 +55,20 @@
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||
<button class="btn btn-primary"
|
||||
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||
(click)="onSubmit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ds-collection-source-controls
|
||||
[isEnabled]="!(hasChanges()|async)"
|
||||
[shouldShow]="contentSource?.harvestType !== harvestTypeNone"
|
||||
[collection]="(collectionRD$ |async)?.payload"
|
||||
>
|
||||
</ds-collection-source-controls>
|
||||
|
||||
|
@@ -62,7 +62,8 @@ describe('CollectionSourceComponent', () => {
|
||||
label: 'DSpace Intermediate Metadata',
|
||||
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
||||
}
|
||||
]
|
||||
],
|
||||
_links: { self: { href: 'contentsource-selflink' } }
|
||||
});
|
||||
fieldUpdate = {
|
||||
field: contentSource,
|
||||
@@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => {
|
||||
updateContentSource: observableOf(contentSource),
|
||||
getHarvesterEndpoint: observableOf('harvester-endpoint')
|
||||
});
|
||||
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']);
|
||||
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||
|
@@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem
|
||||
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
|
||||
take(1)
|
||||
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
|
||||
|
||||
this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href);
|
||||
// Update harvester
|
||||
this.collectionRD$.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
|
@@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
|
||||
import { CollectionSourceComponent } from './collection-source/collection-source.component';
|
||||
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
|
||||
import { CollectionFormModule } from '../collection-form/collection-form.module';
|
||||
import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component';
|
||||
|
||||
/**
|
||||
* Module that contains all components related to the Edit Collection page administrator functionality
|
||||
@@ -26,6 +27,8 @@ import { CollectionFormModule } from '../collection-form/collection-form.module'
|
||||
CollectionRolesComponent,
|
||||
CollectionCurateComponent,
|
||||
CollectionSourceComponent,
|
||||
|
||||
CollectionSourceControlsComponent,
|
||||
CollectionAuthorizationsComponent
|
||||
]
|
||||
})
|
||||
|
@@ -138,7 +138,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
||||
* Get the collection's content harvester
|
||||
* @param collectionId
|
||||
*/
|
||||
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> {
|
||||
getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
|
||||
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
|
||||
isNotEmptyOperator(),
|
||||
take(1)
|
||||
@@ -146,7 +146,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
||||
|
||||
href$.subscribe((href: string) => {
|
||||
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
|
||||
this.requestService.send(request, true);
|
||||
this.requestService.send(request, useCachedVersionIfAvailable);
|
||||
});
|
||||
|
||||
return this.rdbService.buildSingle<ContentSource>(href$);
|
||||
@@ -208,10 +208,20 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item
|
||||
* Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item
|
||||
* @param item Item we want the owning collection of
|
||||
*/
|
||||
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
|
||||
return this.findByHref(item._links.owningCollection.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of mapped collections for the given item.
|
||||
* @param item Item for which the mapped collections should be retrieved.
|
||||
* @param findListOptions Pagination and search options.
|
||||
*/
|
||||
findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||
return this.findAllByHref(item._links.mappedCollections.href, findListOptions);
|
||||
}
|
||||
|
||||
}
|
||||
|
26
src/app/core/shared/content-source-set-serializer.spec.ts
Normal file
26
src/app/core/shared/content-source-set-serializer.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ContentSourceSetSerializer } from './content-source-set-serializer';
|
||||
|
||||
describe('ContentSourceSetSerializer', () => {
|
||||
let serializer: ContentSourceSetSerializer;
|
||||
|
||||
beforeEach(() => {
|
||||
serializer = new ContentSourceSetSerializer();
|
||||
});
|
||||
|
||||
describe('Serialize', () => {
|
||||
it('should return all when the value is empty', () => {
|
||||
expect(serializer.Serialize('')).toEqual('all');
|
||||
});
|
||||
it('should return the value when it is not empty', () => {
|
||||
expect(serializer.Serialize('test-value')).toEqual('test-value');
|
||||
});
|
||||
});
|
||||
describe('Deserialize', () => {
|
||||
it('should return an empty value when the value is \'all\'', () => {
|
||||
expect(serializer.Deserialize('all')).toEqual('');
|
||||
});
|
||||
it('should return the value when it is not \'all\'', () => {
|
||||
expect(serializer.Deserialize('test-value')).toEqual('test-value');
|
||||
});
|
||||
});
|
||||
});
|
31
src/app/core/shared/content-source-set-serializer.ts
Normal file
31
src/app/core/shared/content-source-set-serializer.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { isEmpty } from '../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* Serializer to create convert the 'all' value supported by the server to an empty string and vice versa.
|
||||
*/
|
||||
export class ContentSourceSetSerializer {
|
||||
|
||||
/**
|
||||
* Method to serialize a setId
|
||||
* @param {string} setId
|
||||
* @returns {string} the provided set ID, unless when an empty set ID is provided. In that case, 'all' will be returned.
|
||||
*/
|
||||
Serialize(setId: string): any {
|
||||
if (isEmpty(setId)) {
|
||||
return 'all';
|
||||
}
|
||||
return setId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to deserialize a setId
|
||||
* @param {string} setId
|
||||
* @returns {string} the provided set ID. When 'all' is provided, an empty set ID will be returned.
|
||||
*/
|
||||
Deserialize(setId: string): string {
|
||||
if (setId === 'all') {
|
||||
return '';
|
||||
}
|
||||
return setId;
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import { autoserializeAs, deserializeAs, deserialize } from 'cerialize';
|
||||
import { autoserializeAs, deserialize, deserializeAs, serializeAs } from 'cerialize';
|
||||
import { HALLink } from './hal-link.model';
|
||||
import { MetadataConfig } from './metadata-config.model';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
@@ -6,6 +6,7 @@ import { typedObject } from '../cache/builders/build-decorators';
|
||||
import { CONTENT_SOURCE } from './content-source.resource-type';
|
||||
import { excludeFromEquals } from '../utilities/equals.decorators';
|
||||
import { ResourceType } from './resource-type';
|
||||
import { ContentSourceSetSerializer } from './content-source-set-serializer';
|
||||
|
||||
/**
|
||||
* The type of content harvesting used
|
||||
@@ -49,7 +50,8 @@ export class ContentSource extends CacheableObject {
|
||||
/**
|
||||
* OAI Specific set ID
|
||||
*/
|
||||
@autoserializeAs('oai_set_id')
|
||||
@deserializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
|
||||
@serializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
|
||||
oaiSetId: string;
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,30 @@ export class ContentSource extends CacheableObject {
|
||||
*/
|
||||
metadataConfigs: MetadataConfig[];
|
||||
|
||||
/**
|
||||
* The current harvest status
|
||||
*/
|
||||
@autoserializeAs('harvest_status')
|
||||
harvestStatus: string;
|
||||
|
||||
/**
|
||||
* The last's harvest start time
|
||||
*/
|
||||
@autoserializeAs('harvest_start_time')
|
||||
harvestStartTime: string;
|
||||
|
||||
/**
|
||||
* When the collection was last harvested
|
||||
*/
|
||||
@autoserializeAs('last_harvested')
|
||||
lastHarvested: string;
|
||||
|
||||
/**
|
||||
* The current harvest message
|
||||
*/
|
||||
@autoserializeAs('harvest_message')
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* The {@link HALLink}s for this ContentSource
|
||||
*/
|
||||
|
@@ -1,7 +1,21 @@
|
||||
<ds-metadata-field-wrapper *ngIf="(this.collectionsRD$ | async)?.hasSucceeded" [label]="label | translate">
|
||||
<ds-metadata-field-wrapper [label]="label | translate">
|
||||
<div class="collections">
|
||||
<a *ngFor="let collection of (this.collectionsRD$ | async)?.payload?.page; let last=last;" [routerLink]="['/collections', collection.id]">
|
||||
<a *ngFor="let collection of (this.collections$ | async); let last=last;" [routerLink]="['/collections', collection.id]">
|
||||
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isLoading$ | async">
|
||||
{{'item.page.collections.loading' | translate}}
|
||||
</div>
|
||||
|
||||
<a
|
||||
*ngIf="!(isLoading$ | async) && (hasMore$ | async)"
|
||||
(click)="$event.preventDefault(); handleLoadMore()"
|
||||
class="load-more-btn btn btn-sm btn-outline-secondary"
|
||||
role="button"
|
||||
href="#"
|
||||
>
|
||||
{{'item.page.collections.load-more' | translate}}
|
||||
</a>
|
||||
</ds-metadata-field-wrapper>
|
||||
|
@@ -9,46 +9,45 @@ import { Item } from '../../../core/shared/item.model';
|
||||
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
|
||||
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||
import { CollectionsComponent } from './collections.component';
|
||||
import { FindListOptions } from '../../../core/data/request.models';
|
||||
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||
|
||||
let collectionsComponent: CollectionsComponent;
|
||||
let fixture: ComponentFixture<CollectionsComponent>;
|
||||
|
||||
let collectionDataServiceStub;
|
||||
|
||||
const mockCollection1: Collection = Object.assign(new Collection(), {
|
||||
metadata: {
|
||||
'dc.description.abstract': [
|
||||
{
|
||||
language: 'en_US',
|
||||
value: 'Short description'
|
||||
}
|
||||
]
|
||||
},
|
||||
_links: {
|
||||
self: { href: 'collection-selflink' }
|
||||
}
|
||||
const createMockCollection = (id: string) => Object.assign(new Collection(), {
|
||||
id: id,
|
||||
name: `collection-${id}`,
|
||||
});
|
||||
|
||||
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)});
|
||||
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)});
|
||||
const mockItem: Item = new Item();
|
||||
|
||||
describe('CollectionsComponent', () => {
|
||||
collectionDataServiceStub = {
|
||||
findOwningCollectionFor(item: Item) {
|
||||
if (item === succeededMockItem) {
|
||||
return createSuccessfulRemoteDataObject$(mockCollection1);
|
||||
} else {
|
||||
return createFailedRemoteDataObject$('error', 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
let collectionDataService;
|
||||
|
||||
let mockCollection1: Collection;
|
||||
let mockCollection2: Collection;
|
||||
let mockCollection3: Collection;
|
||||
let mockCollection4: Collection;
|
||||
|
||||
let component: CollectionsComponent;
|
||||
let fixture: ComponentFixture<CollectionsComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
collectionDataService = jasmine.createSpyObj([
|
||||
'findOwningCollectionFor',
|
||||
'findMappedCollectionsFor',
|
||||
]);
|
||||
|
||||
mockCollection1 = createMockCollection('c1');
|
||||
mockCollection2 = createMockCollection('c2');
|
||||
mockCollection3 = createMockCollection('c3');
|
||||
mockCollection4 = createMockCollection('c4');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [ CollectionsComponent ],
|
||||
providers: [
|
||||
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
|
||||
{ provide: CollectionDataService, useValue: collectionDataServiceStub },
|
||||
{ provide: CollectionDataService, useValue: collectionDataService },
|
||||
],
|
||||
|
||||
schemas: [ NO_ERRORS_SCHEMA ]
|
||||
@@ -59,33 +58,264 @@ describe('CollectionsComponent', () => {
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
fixture = TestBed.createComponent(CollectionsComponent);
|
||||
collectionsComponent = fixture.componentInstance;
|
||||
collectionsComponent.label = 'test.test';
|
||||
collectionsComponent.separator = '<br/>';
|
||||
|
||||
component = fixture.componentInstance;
|
||||
component.item = mockItem;
|
||||
component.label = 'test.test';
|
||||
component.separator = '<br/>';
|
||||
component.pageSize = 2;
|
||||
}));
|
||||
|
||||
describe('When the requested item request has succeeded', () => {
|
||||
describe('when the item has only an owning collection', () => {
|
||||
let mockPage1: PaginatedList<Collection>;
|
||||
|
||||
beforeEach(() => {
|
||||
collectionsComponent.item = succeededMockItem;
|
||||
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 0,
|
||||
totalElements: 0,
|
||||
}), []);
|
||||
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show the collection', () => {
|
||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
||||
expect(collectionField).not.toBeNull();
|
||||
it('should display the owning collection', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(1);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(1);
|
||||
expect(component.hasMore$.getValue()).toBe(false);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When the requested item request has failed', () => {
|
||||
describe('when the item has an owning collection and one mapped collection', () => {
|
||||
let mockPage1: PaginatedList<Collection>;
|
||||
|
||||
beforeEach(() => {
|
||||
collectionsComponent.item = failedMockItem;
|
||||
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 1,
|
||||
totalElements: 1,
|
||||
}), [mockCollection2]);
|
||||
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should not show the collection', () => {
|
||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
||||
expect(collectionField).toBeNull();
|
||||
it('should display the owning collection and the mapped collection', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(2);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(1);
|
||||
expect(component.hasMore$.getValue()).toBe(false);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the item has an owning collection and multiple mapped collections', () => {
|
||||
let mockPage1: PaginatedList<Collection>;
|
||||
let mockPage2: PaginatedList<Collection>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 2,
|
||||
totalElements: 3,
|
||||
}), [mockCollection2, mockCollection3]);
|
||||
|
||||
mockPage2 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 2,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 2,
|
||||
totalElements: 1,
|
||||
}), [mockCollection4]);
|
||||
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValues(
|
||||
createSuccessfulRemoteDataObject$(mockPage1),
|
||||
createSuccessfulRemoteDataObject$(mockPage2),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the owning collection, two mapped collections and a load more button', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(3);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
|
||||
expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(1);
|
||||
expect(component.hasMore$.getValue()).toBe(true);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('when the load more button is clicked', () => {
|
||||
beforeEach(() => {
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
loadMoreBtn.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the owning collection and three mapped collections', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledTimes(2);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 2,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(4);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
|
||||
expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3');
|
||||
expect(collectionFields[3].nativeElement.textContent).toEqual('collection-c4');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(2);
|
||||
expect(component.hasMore$.getValue()).toBe(false);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the request for the owning collection fails', () => {
|
||||
let mockPage1: PaginatedList<Collection>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 1,
|
||||
totalElements: 1,
|
||||
}), [mockCollection2]);
|
||||
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$());
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the mapped collection only', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(1);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c2');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(1);
|
||||
expect(component.hasMore$.getValue()).toBe(false);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the request for the mapped collections fails', () => {
|
||||
beforeEach(() => {
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$());
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the owning collection only', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(1);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(0);
|
||||
expect(component.hasMore$.getValue()).toBe(true);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when both requests fail', () => {
|
||||
beforeEach(() => {
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$());
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$());
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display no collections', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(0);
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(0);
|
||||
expect(component.hasMore$.getValue()).toBe(true);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,14 +1,19 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||
import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators';
|
||||
import { CollectionDataService } from '../../../core/data/collection-data.service';
|
||||
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { FindListOptions } from '../../../core/data/request.models';
|
||||
import {
|
||||
getAllCompletedRemoteData,
|
||||
getAllSucceededRemoteDataPayload,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getPaginatedListPayload,
|
||||
} from '../../../core/shared/operators';
|
||||
|
||||
/**
|
||||
* This component renders the parent collections section of the item
|
||||
@@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit {
|
||||
|
||||
separator = '<br/>';
|
||||
|
||||
collectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
|
||||
/**
|
||||
* Amount of mapped collections that should be fetched at once.
|
||||
*/
|
||||
pageSize = 5;
|
||||
|
||||
/**
|
||||
* Last page of the mapped collections that has been fetched.
|
||||
*/
|
||||
lastPage$: BehaviorSubject<number> = new BehaviorSubject<number>(0);
|
||||
|
||||
/**
|
||||
* Push an event to this observable to fetch the next page of mapped collections.
|
||||
* Because this observable is a behavior subject, the first page will be requested
|
||||
* immediately after subscription.
|
||||
*/
|
||||
loadMore$: BehaviorSubject<void> = new BehaviorSubject(undefined);
|
||||
|
||||
/**
|
||||
* Whether or not a page of mapped collections is currently being loaded.
|
||||
*/
|
||||
isLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
|
||||
|
||||
/**
|
||||
* Whether or not more pages of mapped collections are available.
|
||||
*/
|
||||
hasMore$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
|
||||
|
||||
/**
|
||||
* All collections that have been retrieved so far. This includes the owning collection,
|
||||
* as well as any number of pages of mapped collections.
|
||||
*/
|
||||
collections$: Observable<Collection[]>;
|
||||
|
||||
constructor(private cds: CollectionDataService) {
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// this.collections = this.item.parents.payload;
|
||||
const owningCollection$: Observable<Collection> = this.cds.findOwningCollectionFor(this.item).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
startWith(null as Collection),
|
||||
);
|
||||
|
||||
// TODO: this should use parents, but the collections
|
||||
// for an Item aren't returned by the REST API yet,
|
||||
// only the owning collection
|
||||
this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe(
|
||||
map((rd: RemoteData<Collection>) => {
|
||||
if (hasValue(rd.payload)) {
|
||||
return new RemoteData(
|
||||
rd.timeCompleted,
|
||||
rd.msToLive,
|
||||
rd.lastUpdated,
|
||||
rd.state,
|
||||
rd.errorMessage,
|
||||
buildPaginatedList({
|
||||
elementsPerPage: 10,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
totalElements: 1,
|
||||
_links: {
|
||||
self: rd.payload._links.self
|
||||
}
|
||||
} as PageInfo, [rd.payload]),
|
||||
rd.statusCode
|
||||
);
|
||||
} else {
|
||||
return rd as any;
|
||||
}
|
||||
})
|
||||
const mappedCollections$: Observable<Collection[]> = this.loadMore$.pipe(
|
||||
// update isLoading$
|
||||
tap(() => this.isLoading$.next(true)),
|
||||
|
||||
// request next batch of mapped collections
|
||||
withLatestFrom(this.lastPage$),
|
||||
switchMap(([_, lastPage]: [void, number]) => {
|
||||
return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: this.pageSize,
|
||||
currentPage: lastPage + 1,
|
||||
}));
|
||||
}),
|
||||
|
||||
getAllCompletedRemoteData<PaginatedList<Collection>>(),
|
||||
|
||||
// update isLoading$
|
||||
tap(() => this.isLoading$.next(false)),
|
||||
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
|
||||
// update hasMore$
|
||||
tap((response: PaginatedList<Collection>) => this.hasMore$.next(response.currentPage < response.totalPages)),
|
||||
|
||||
// update lastPage$
|
||||
tap((response: PaginatedList<Collection>) => this.lastPage$.next(response.currentPage)),
|
||||
|
||||
getPaginatedListPayload<Collection>(),
|
||||
|
||||
// add current batch to list of collections
|
||||
scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []),
|
||||
|
||||
startWith([]),
|
||||
) as Observable<Collection[]>;
|
||||
|
||||
this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe(
|
||||
map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => {
|
||||
return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
handleLoadMore() {
|
||||
this.loadMore$.next();
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -31,6 +31,8 @@ import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/med
|
||||
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
|
||||
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
|
||||
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
|
||||
import { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component';
|
||||
|
||||
|
||||
const ENTRY_COMPONENTS = [
|
||||
// put only entry components that use custom decorator
|
||||
@@ -39,6 +41,7 @@ const ENTRY_COMPONENTS = [
|
||||
];
|
||||
|
||||
const DECLARATIONS = [
|
||||
ThemedFileSectionComponent,
|
||||
ItemPageComponent,
|
||||
ThemedItemPageComponent,
|
||||
FullItemPageComponent,
|
||||
|
@@ -0,0 +1,28 @@
|
||||
import { ThemedComponent } from '../../../../shared/theme-support/themed.component';
|
||||
import { FileSectionComponent } from './file-section.component';
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {Item} from '../../../../core/shared/item.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-themed-item-page-file-section',
|
||||
templateUrl: '../../../../shared/theme-support/themed.component.html',
|
||||
})
|
||||
export class ThemedFileSectionComponent extends ThemedComponent<FileSectionComponent> {
|
||||
|
||||
@Input() item: Item;
|
||||
|
||||
protected inAndOutputNames: (keyof FileSectionComponent & keyof this)[] = ['item'];
|
||||
|
||||
protected getComponentName(): string {
|
||||
return 'FileSectionComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../../../../themes/${themeName}/app/item-page/simple/field-components/file-section/file-section.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import(`./file-section.component`);
|
||||
}
|
||||
|
||||
}
|
@@ -25,7 +25,7 @@
|
||||
<ng-container *ngIf="mediaViewer.image">
|
||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||
</ng-container>
|
||||
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
|
||||
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
|
||||
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||
[parentItem]="object"
|
||||
|
@@ -25,7 +25,7 @@
|
||||
<ng-container *ngIf="mediaViewer.image">
|
||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||
</ng-container>
|
||||
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
|
||||
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
|
||||
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||
[parentItem]="object"
|
||||
|
@@ -1,5 +1,8 @@
|
||||
<div class="nav-item dropdown expandable-navbar-section"
|
||||
(keyup.enter)="activateSection($event)"
|
||||
*ngVar="(active | async) as isActive"
|
||||
(keyup.enter)="isActive ? deactivateSection($event) : activateSection($event)"
|
||||
(keyup.space)="isActive ? deactivateSection($event) : activateSection($event)"
|
||||
(keydown.space)="$event.preventDefault()"
|
||||
(mouseenter)="activateSection($event)"
|
||||
(mouseleave)="deactivateSection($event)">
|
||||
<a href="#" class="nav-link dropdown-toggle" routerLinkActive="active"
|
||||
|
@@ -9,6 +9,7 @@ import { HostWindowService } from '../../shared/host-window.service';
|
||||
import { MenuService } from '../../shared/menu/menu.service';
|
||||
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { VarDirective } from '../../shared/utils/var.directive';
|
||||
|
||||
describe('ExpandableNavbarSectionComponent', () => {
|
||||
let component: ExpandableNavbarSectionComponent;
|
||||
@@ -19,7 +20,7 @@ describe('ExpandableNavbarSectionComponent', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule],
|
||||
declarations: [ExpandableNavbarSectionComponent, TestComponent],
|
||||
declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
|
||||
providers: [
|
||||
{ provide: 'sectionDataProvider', useValue: {} },
|
||||
{ provide: MenuService, useValue: menuService },
|
||||
@@ -76,6 +77,78 @@ describe('ExpandableNavbarSectionComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when Enter key is pressed on section header (while inactive)', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'activateSection');
|
||||
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
|
||||
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
|
||||
// dispatch the (keyup.enter) action used in our component HTML
|
||||
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
|
||||
});
|
||||
|
||||
it('should call activateSection on the menuService', () => {
|
||||
expect(menuService.activateSection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when Enter key is pressed on section header (while active)', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'deactivateSection');
|
||||
// Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property.
|
||||
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true));
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
|
||||
// dispatch the (keyup.enter) action used in our component HTML
|
||||
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
|
||||
});
|
||||
|
||||
it('should call deactivateSection on the menuService', () => {
|
||||
expect(menuService.deactivateSection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when spacebar is pressed on section header (while inactive)', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'activateSection');
|
||||
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
|
||||
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
|
||||
// dispatch the (keyup.space) action used in our component HTML
|
||||
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
|
||||
});
|
||||
|
||||
it('should call activateSection on the menuService', () => {
|
||||
expect(menuService.activateSection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when spacebar is pressed on section header (while active)', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'deactivateSection');
|
||||
// Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property.
|
||||
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true));
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
|
||||
// dispatch the (keyup.space) action used in our component HTML
|
||||
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
|
||||
});
|
||||
|
||||
it('should call deactivateSection on the menuService', () => {
|
||||
expect(menuService.deactivateSection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a click occurs on the section header', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'toggleActiveSection');
|
||||
@@ -96,7 +169,7 @@ describe('ExpandableNavbarSectionComponent', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule],
|
||||
declarations: [ExpandableNavbarSectionComponent, TestComponent],
|
||||
declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
|
||||
providers: [
|
||||
{ provide: 'sectionDataProvider', useValue: {} },
|
||||
{ provide: MenuService, useValue: menuService },
|
||||
|
@@ -21,11 +21,14 @@ import { storeModuleConfig } from '../../app.reducer';
|
||||
import { FindListOptions } from '../../core/data/request.models';
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { PaginationServiceStub } from '../testing/pagination-service.stub';
|
||||
import { ThemeService } from '../theme-support/theme.service';
|
||||
|
||||
describe('BrowseByComponent', () => {
|
||||
let comp: BrowseByComponent;
|
||||
let fixture: ComponentFixture<BrowseByComponent>;
|
||||
|
||||
let themeService: ThemeService;
|
||||
|
||||
const mockItems = [
|
||||
Object.assign(new Item(), {
|
||||
id: 'fakeId-1',
|
||||
@@ -57,6 +60,9 @@ describe('BrowseByComponent', () => {
|
||||
const paginationService = new PaginationServiceStub(paginationConfig);
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
themeService = jasmine.createSpyObj('themeService', {
|
||||
getThemeName: 'dspace',
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
@@ -75,7 +81,8 @@ describe('BrowseByComponent', () => {
|
||||
],
|
||||
declarations: [],
|
||||
providers: [
|
||||
{provide: PaginationService, useValue: paginationService}
|
||||
{provide: PaginationService, useValue: paginationService},
|
||||
{ provide: ThemeService, useValue: themeService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
|
@@ -7,12 +7,17 @@ import {
|
||||
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
|
||||
import { Context } from '../../core/shared/context.model';
|
||||
import * as uuidv4 from 'uuid/v4';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
let ogEnvironmentThemes;
|
||||
|
||||
describe('MetadataRepresentation decorator function', () => {
|
||||
const type1 = 'TestType';
|
||||
const type2 = 'TestType2';
|
||||
const type3 = 'TestType3';
|
||||
const type4 = 'RandomType';
|
||||
const typeAncestor = 'TestTypeAncestor';
|
||||
const typeUnthemed = 'TestTypeUnthemed';
|
||||
let prefix;
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => {
|
||||
class Test3ItemSubmission {
|
||||
}
|
||||
|
||||
class TestAncestorComponent {
|
||||
}
|
||||
|
||||
class TestUnthemedComponent {
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -46,8 +57,18 @@ describe('MetadataRepresentation decorator function', () => {
|
||||
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
|
||||
|
||||
metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission);
|
||||
|
||||
// Register a metadata representation in the 'ancestor' theme
|
||||
metadataRepresentationComponent(key + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'ancestor')(TestAncestorComponent);
|
||||
metadataRepresentationComponent(key + typeUnthemed, MetadataRepresentationType.Item, Context.Any)(TestUnthemedComponent);
|
||||
|
||||
ogEnvironmentThemes = environment.themes;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
environment.themes = ogEnvironmentThemes;
|
||||
});
|
||||
|
||||
describe('If there\'s an exact match', () => {
|
||||
it('should return the matching class', () => {
|
||||
const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace);
|
||||
@@ -76,4 +97,55 @@ describe('MetadataRepresentation decorator function', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('With theme extensions', () => {
|
||||
// We're only interested in the cases that the requested theme doesn't match the requested entityType,
|
||||
// as the cases where it does are already covered by the tests above
|
||||
describe('If requested theme has no match', () => {
|
||||
beforeEach(() => {
|
||||
environment.themes = [
|
||||
{
|
||||
name: 'requested', // Doesn't match any entityType
|
||||
extends: 'intermediate',
|
||||
},
|
||||
{
|
||||
name: 'intermediate', // Doesn't match any entityType
|
||||
extends: 'ancestor',
|
||||
},
|
||||
{
|
||||
name: 'ancestor', // Matches typeAncestor, but not typeUnthemed
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
it('should return component from the first ancestor theme that matches its entityType', () => {
|
||||
const component = getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'requested');
|
||||
expect(component).toEqual(TestAncestorComponent);
|
||||
});
|
||||
|
||||
it('should return default component if none of the ancestor themes match its entityType', () => {
|
||||
const component = getMetadataRepresentationComponent(prefix + typeUnthemed, MetadataRepresentationType.Item, Context.Any, 'requested');
|
||||
expect(component).toEqual(TestUnthemedComponent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('If there is a theme extension cycle', () => {
|
||||
beforeEach(() => {
|
||||
environment.themes = [
|
||||
{ name: 'extension-cycle', extends: 'broken1' },
|
||||
{ name: 'broken1', extends: 'broken2' },
|
||||
{ name: 'broken2', extends: 'broken3' },
|
||||
{ name: 'broken3', extends: 'broken1' },
|
||||
];
|
||||
});
|
||||
|
||||
it('should throw an error', () => {
|
||||
expect(() => {
|
||||
getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'extension-cycle');
|
||||
}).toThrowError(
|
||||
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -3,6 +3,10 @@ import { hasNoValue, hasValue } from '../empty.util';
|
||||
import { Context } from '../../core/shared/context.model';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
||||
import {
|
||||
resolveTheme,
|
||||
DEFAULT_THEME, DEFAULT_CONTEXT
|
||||
} from '../object-collection/shared/listable-object/listable-object.decorator';
|
||||
|
||||
export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', {
|
||||
providedIn: 'root',
|
||||
@@ -13,8 +17,6 @@ export const map = new Map();
|
||||
|
||||
export const DEFAULT_ENTITY_TYPE = 'Publication';
|
||||
export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText;
|
||||
export const DEFAULT_CONTEXT = Context.Any;
|
||||
export const DEFAULT_THEME = '*';
|
||||
|
||||
/**
|
||||
* Decorator function to store metadata representation mapping
|
||||
@@ -57,8 +59,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese
|
||||
if (hasValue(entityAndMDRepMap)) {
|
||||
const contextMap = entityAndMDRepMap.get(context);
|
||||
if (hasValue(contextMap)) {
|
||||
if (hasValue(contextMap.get(theme))) {
|
||||
return contextMap.get(theme);
|
||||
const match = resolveTheme(contextMap, theme);
|
||||
if (hasValue(match)) {
|
||||
return match;
|
||||
}
|
||||
if (hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||
return contextMap.get(DEFAULT_THEME);
|
||||
|
@@ -1,9 +1,18 @@
|
||||
import { ThemeService } from '../theme-support/theme.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { ThemeConfig } from '../../../config/theme.model';
|
||||
import { isNotEmpty } from '../empty.util';
|
||||
|
||||
export function getMockThemeService(themeName = 'base'): ThemeService {
|
||||
return jasmine.createSpyObj('themeService', {
|
||||
export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
|
||||
const spy = jasmine.createSpyObj('themeService', {
|
||||
getThemeName: themeName,
|
||||
getThemeName$: observableOf(themeName)
|
||||
getThemeName$: observableOf(themeName),
|
||||
getThemeConfigFor: undefined,
|
||||
});
|
||||
|
||||
if (isNotEmpty(themes)) {
|
||||
spy.getThemeConfigFor.and.callFake((name: string) => themes.find(theme => theme.name === name));
|
||||
}
|
||||
|
||||
return spy;
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
|
||||
import { BrowserModule, By } from '@angular/platform-browser';
|
||||
import { ChangeDetectorRef, DebugElement } from '@angular/core';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
@@ -16,6 +16,7 @@ import { Notification } from '../models/notification.model';
|
||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
|
||||
import { storeModuleConfig } from '../../../app.reducer';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
describe('NotificationComponent', () => {
|
||||
|
||||
@@ -83,6 +84,8 @@ describe('NotificationComponent', () => {
|
||||
deContent = fixture.debugElement.query(By.css('.notification-content'));
|
||||
elContent = deContent.nativeElement;
|
||||
elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
|
||||
|
||||
spyOn(comp, 'remove');
|
||||
});
|
||||
|
||||
it('should create component', () => {
|
||||
@@ -124,4 +127,51 @@ describe('NotificationComponent', () => {
|
||||
expect(elContent.innerHTML).toEqual(htmlContent);
|
||||
});
|
||||
|
||||
describe('dismiss countdown', () => {
|
||||
const TIMEOUT = 5000;
|
||||
let isPaused$: BehaviorSubject<boolean>;
|
||||
|
||||
beforeEach(() => {
|
||||
isPaused$ = new BehaviorSubject<boolean>(false);
|
||||
comp.isPaused$ = isPaused$;
|
||||
comp.notification = {
|
||||
id: '1',
|
||||
type: NotificationType.Info,
|
||||
title: 'Notif. title',
|
||||
content: 'test',
|
||||
options: Object.assign(
|
||||
new NotificationOptions(),
|
||||
{ timeout: TIMEOUT }
|
||||
),
|
||||
html: true
|
||||
};
|
||||
});
|
||||
|
||||
it('should remove notification after timeout', fakeAsync(() => {
|
||||
comp.ngOnInit();
|
||||
tick(TIMEOUT);
|
||||
expect(comp.remove).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
describe('isPaused$', () => {
|
||||
it('should pause countdown on true', fakeAsync(() => {
|
||||
comp.ngOnInit();
|
||||
tick(TIMEOUT / 2);
|
||||
isPaused$.next(true);
|
||||
tick(TIMEOUT);
|
||||
expect(comp.remove).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should resume paused countdown on false', fakeAsync(() => {
|
||||
comp.ngOnInit();
|
||||
tick(TIMEOUT / 4);
|
||||
isPaused$.next(true);
|
||||
tick(TIMEOUT / 4);
|
||||
isPaused$.next(false);
|
||||
tick(TIMEOUT);
|
||||
expect(comp.remove).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import {of as observableOf, Observable } from 'rxjs';
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
@@ -23,6 +23,7 @@ import { fadeInEnter, fadeInState, fadeOutLeave, fadeOutState } from '../../anim
|
||||
import { NotificationAnimationsStatus } from '../models/notification-animations-type';
|
||||
import { isNotEmpty } from '../../empty.util';
|
||||
import { INotification } from '../models/notification.model';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-notification',
|
||||
@@ -47,6 +48,11 @@ export class NotificationComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() public notification = null as INotification;
|
||||
|
||||
/**
|
||||
* Whether this notification's countdown should be paused
|
||||
*/
|
||||
@Input() public isPaused$: Observable<boolean> = observableOf(false);
|
||||
|
||||
// Progress bar variables
|
||||
public title: Observable<string>;
|
||||
public content: Observable<string>;
|
||||
@@ -99,9 +105,12 @@ export class NotificationComponent implements OnInit, OnDestroy {
|
||||
private instance = () => {
|
||||
this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
|
||||
|
||||
this.isPaused$.pipe(
|
||||
filter(paused => !paused),
|
||||
first(),
|
||||
).subscribe(() => {
|
||||
if (this.count++ === this.steps) {
|
||||
this.remove();
|
||||
// this.item.timeoutEnd!.emit();
|
||||
} else if (!this.stopTime) {
|
||||
if (this.showProgressBar) {
|
||||
this.progressWidth += 100 / this.steps;
|
||||
@@ -110,6 +119,7 @@ export class NotificationComponent implements OnInit, OnDestroy {
|
||||
this.timer = setTimeout(this.instance, (this.speed - this.diff));
|
||||
}
|
||||
this.zone.run(() => this.cdr.detectChanges());
|
||||
});
|
||||
}
|
||||
|
||||
public remove() {
|
||||
|
@@ -1,7 +1,10 @@
|
||||
<div class="notifications-wrapper position-fixed" [ngClass]="position">
|
||||
<div class="notifications-wrapper position-fixed"
|
||||
[ngClass]="position"
|
||||
(mouseenter)="this.isPaused$.next(true);"
|
||||
(mouseleave)="this.isPaused$.next(false);">
|
||||
<ds-notification
|
||||
class="notification"
|
||||
*ngFor="let a of notifications; let i = index"
|
||||
[notification]="a">
|
||||
[notification]="a" [isPaused$]="isPaused$">
|
||||
</ds-notification>
|
||||
</div>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserModule, By } from '@angular/platform-browser';
|
||||
import { ChangeDetectorRef } from '@angular/core';
|
||||
|
||||
import { NotificationsService } from '../notifications.service';
|
||||
@@ -14,6 +14,9 @@ import { NotificationType } from '../models/notification-type';
|
||||
import { uniqueId } from 'lodash';
|
||||
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
|
||||
import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
|
||||
import { cold } from 'jasmine-marbles';
|
||||
|
||||
export const bools = { f: false, t: true };
|
||||
|
||||
describe('NotificationsBoardComponent', () => {
|
||||
let comp: NotificationsBoardComponent;
|
||||
@@ -67,6 +70,40 @@ describe('NotificationsBoardComponent', () => {
|
||||
|
||||
it('should have two notifications', () => {
|
||||
expect(comp.notifications.length).toBe(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('ds-notification')).length).toBe(2);
|
||||
});
|
||||
|
||||
describe('notification countdown', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = fixture.debugElement.query(By.css('div.notifications-wrapper'));
|
||||
});
|
||||
|
||||
it('should not be paused by default', () => {
|
||||
expect(comp.isPaused$).toBeObservable(cold('f', bools));
|
||||
});
|
||||
|
||||
it('should pause on mouseenter', () => {
|
||||
wrapper.triggerEventHandler('mouseenter');
|
||||
|
||||
expect(comp.isPaused$).toBeObservable(cold('t', bools));
|
||||
});
|
||||
|
||||
it('should resume on mouseleave', () => {
|
||||
wrapper.triggerEventHandler('mouseenter');
|
||||
wrapper.triggerEventHandler('mouseleave');
|
||||
|
||||
expect(comp.isPaused$).toBeObservable(cold('f', bools));
|
||||
});
|
||||
|
||||
it('should be passed to all notifications', () => {
|
||||
fixture.debugElement.queryAll(By.css('ds-notification'))
|
||||
.map(node => node.componentInstance)
|
||||
.forEach(notification => {
|
||||
expect(notification.isPaused$).toEqual(comp.isPaused$);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
})
|
||||
|
@@ -9,7 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
|
||||
import { select, Store } from '@ngrx/store';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||
import { difference } from 'lodash';
|
||||
|
||||
import { NotificationsService } from '../notifications.service';
|
||||
@@ -44,6 +44,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
|
||||
public rtl = false;
|
||||
public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight';
|
||||
|
||||
/**
|
||||
* Whether to pause the dismiss countdown of all notifications on the board
|
||||
*/
|
||||
public isPaused$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
|
||||
constructor(private service: NotificationsService,
|
||||
private store: Store<AppState>,
|
||||
private cdr: ChangeDetectorRef) {
|
||||
@@ -129,7 +134,6 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.sub) {
|
||||
this.sub.unsubscribe();
|
||||
|
@@ -11,6 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Item } from '../../../../core/shared/item.model';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { ThemeService } from '../../../theme-support/theme.service';
|
||||
|
||||
const testType = 'TestType';
|
||||
const testContext = Context.Search;
|
||||
@@ -26,12 +27,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
||||
let comp: ListableObjectComponentLoaderComponent;
|
||||
let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>;
|
||||
|
||||
let themeService: ThemeService;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
themeService = jasmine.createSpyObj('themeService', {
|
||||
getThemeName: 'dspace',
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
providers: [provideMockStore({})]
|
||||
providers: [
|
||||
provideMockStore({}),
|
||||
{ provide: ThemeService, useValue: themeService },
|
||||
]
|
||||
}).overrideComponent(ListableObjectComponentLoaderComponent, {
|
||||
set: {
|
||||
changeDetection: ChangeDetectionStrategy.Default,
|
||||
@@ -48,6 +57,7 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
||||
comp.viewMode = testViewMode;
|
||||
comp.context = testContext;
|
||||
spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any);
|
||||
spyOn(comp as any, 'connectInputsAndOutputs').and.callThrough();
|
||||
fixture.detectChanges();
|
||||
|
||||
}));
|
||||
@@ -56,6 +66,10 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
||||
it('should call the getListableObjectComponent function with the right types, view mode and context', () => {
|
||||
expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext);
|
||||
});
|
||||
|
||||
it('should connectInputsAndOutputs of loaded component', () => {
|
||||
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the object is an item and viewMode is a list', () => {
|
||||
@@ -121,20 +135,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
||||
let reloadedObject: any;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn((comp as any), 'connectInputsAndOutputs').and.returnValue(null);
|
||||
spyOn((comp as any), 'instantiateComponent').and.returnValue(null);
|
||||
spyOn((comp as any).contentChange, 'emit').and.returnValue(null);
|
||||
|
||||
listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
|
||||
reloadedObject = 'object';
|
||||
});
|
||||
|
||||
it('should pass it on connectInputsAndOutputs', fakeAsync(() => {
|
||||
expect((comp as any).connectInputsAndOutputs).not.toHaveBeenCalled();
|
||||
it('should re-instantiate the listable component', fakeAsync(() => {
|
||||
expect((comp as any).instantiateComponent).not.toHaveBeenCalled();
|
||||
|
||||
(listableComponent as any).reloadedObject.emit(reloadedObject);
|
||||
tick();
|
||||
|
||||
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
|
||||
expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject);
|
||||
}));
|
||||
|
||||
it('should re-emit it as a contentChange', fakeAsync(() => {
|
||||
|
@@ -184,7 +184,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges
|
||||
if (reloadedObject) {
|
||||
this.compRef.destroy();
|
||||
this.object = reloadedObject;
|
||||
this.connectInputsAndOutputs();
|
||||
this.instantiateComponent(reloadedObject);
|
||||
this.contentChange.emit(reloadedObject);
|
||||
}
|
||||
});
|
||||
|
@@ -2,11 +2,16 @@ import { Item } from '../../../../core/shared/item.model';
|
||||
import { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
|
||||
import { Context } from '../../../../core/shared/context.model';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
|
||||
let ogEnvironmentThemes;
|
||||
|
||||
describe('ListableObject decorator function', () => {
|
||||
const type1 = 'TestType';
|
||||
const type2 = 'TestType2';
|
||||
const type3 = 'TestType3';
|
||||
const typeAncestor = 'TestTypeAncestor';
|
||||
const typeUnthemed = 'TestTypeUnthemed';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
class Test1List {
|
||||
@@ -27,6 +32,12 @@ describe('ListableObject decorator function', () => {
|
||||
class Test3DetailedSubmission {
|
||||
}
|
||||
|
||||
class TestAncestorComponent {
|
||||
}
|
||||
|
||||
class TestUnthemedComponent {
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,6 +49,16 @@ describe('ListableObject decorator function', () => {
|
||||
|
||||
listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
|
||||
listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission);
|
||||
|
||||
// Register a metadata representation in the 'ancestor' theme
|
||||
listableObjectComponent(typeAncestor, ViewMode.ListElement, Context.Any, 'ancestor')(TestAncestorComponent);
|
||||
listableObjectComponent(typeUnthemed, ViewMode.ListElement, Context.Any)(TestUnthemedComponent);
|
||||
|
||||
ogEnvironmentThemes = environment.themes;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.themes = ogEnvironmentThemes;
|
||||
});
|
||||
|
||||
const gridDecorator = listableObjectComponent('Item', ViewMode.GridElement);
|
||||
@@ -80,4 +101,55 @@ describe('ListableObject decorator function', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('With theme extensions', () => {
|
||||
// We're only interested in the cases that the requested theme doesn't match the requested objectType,
|
||||
// as the cases where it does are already covered by the tests above
|
||||
describe('If requested theme has no match', () => {
|
||||
beforeEach(() => {
|
||||
environment.themes = [
|
||||
{
|
||||
name: 'requested', // Doesn't match any objectType
|
||||
extends: 'intermediate',
|
||||
},
|
||||
{
|
||||
name: 'intermediate', // Doesn't match any objectType
|
||||
extends: 'ancestor',
|
||||
},
|
||||
{
|
||||
name: 'ancestor', // Matches typeAncestor, but not typeUnthemed
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
it('should return component from the first ancestor theme that matches its objectType', () => {
|
||||
const component = getListableObjectComponent([typeAncestor], ViewMode.ListElement, Context.Any, 'requested');
|
||||
expect(component).toEqual(TestAncestorComponent);
|
||||
});
|
||||
|
||||
it('should return default component if none of the ancestor themes match its objectType', () => {
|
||||
const component = getListableObjectComponent([typeUnthemed], ViewMode.ListElement, Context.Any, 'requested');
|
||||
expect(component).toEqual(TestUnthemedComponent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('If there is a theme extension cycle', () => {
|
||||
beforeEach(() => {
|
||||
environment.themes = [
|
||||
{ name: 'extension-cycle', extends: 'broken1' },
|
||||
{ name: 'broken1', extends: 'broken2' },
|
||||
{ name: 'broken2', extends: 'broken3' },
|
||||
{ name: 'broken3', extends: 'broken1' },
|
||||
];
|
||||
});
|
||||
|
||||
it('should throw an error', () => {
|
||||
expect(() => {
|
||||
getListableObjectComponent([typeAncestor], ViewMode.ListElement, Context.Any, 'extension-cycle');
|
||||
}).toThrowError(
|
||||
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,14 +1,23 @@
|
||||
import { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||
import { Context } from '../../../../core/shared/context.model';
|
||||
import { hasNoValue, hasValue } from '../../../empty.util';
|
||||
import {
|
||||
DEFAULT_CONTEXT,
|
||||
DEFAULT_THEME
|
||||
} from '../../../metadata-representation/metadata-representation.decorator';
|
||||
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 { InjectionToken } from '@angular/core';
|
||||
|
||||
export const DEFAULT_VIEW_MODE = ViewMode.ListElement;
|
||||
export const DEFAULT_CONTEXT = Context.Any;
|
||||
export const DEFAULT_THEME = '*';
|
||||
|
||||
/**
|
||||
* Factory to allow us to inject getThemeConfigFor so we can mock it in tests
|
||||
*/
|
||||
export const GET_THEME_CONFIG_FOR_FACTORY = new InjectionToken<(str) => ThemeConfig>('getThemeConfigFor', {
|
||||
providedIn: 'root',
|
||||
factory: () => getThemeConfigFor
|
||||
});
|
||||
|
||||
const map = new Map();
|
||||
|
||||
@@ -54,8 +63,9 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
|
||||
if (hasValue(typeModeMap)) {
|
||||
const contextMap = typeModeMap.get(context);
|
||||
if (hasValue(contextMap)) {
|
||||
if (hasValue(contextMap.get(theme))) {
|
||||
return contextMap.get(theme);
|
||||
const match = resolveTheme(contextMap, theme);
|
||||
if (hasValue(match)) {
|
||||
return match;
|
||||
}
|
||||
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||
bestMatchValue = 3;
|
||||
@@ -80,3 +90,35 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a ThemeConfig by its name;
|
||||
*/
|
||||
export const getThemeConfigFor = (themeName: string): ThemeConfig => {
|
||||
return environment.themes.find(theme => theme.name === themeName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a match in the given map for the given theme name, taking theme extension into account
|
||||
*
|
||||
* @param contextMap A map of theme names to components
|
||||
* @param themeName The name of the theme to check
|
||||
* @param checkedThemeNames The list of theme names that are already checked
|
||||
*/
|
||||
export const resolveTheme = (contextMap: Map<any, any>, themeName: string, checkedThemeNames: string[] = []): any => {
|
||||
const match = contextMap.get(themeName);
|
||||
if (hasValue(match)) {
|
||||
return match;
|
||||
} else {
|
||||
const cfg = getThemeConfigFor(themeName);
|
||||
if (hasValue(cfg) && isNotEmpty(cfg.extends)) {
|
||||
const nextTheme = cfg.extends;
|
||||
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
|
||||
if (checkedThemeNames.includes(nextTheme)) {
|
||||
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
|
||||
} else {
|
||||
return resolveTheme(contextMap, nextTheme, nextCheckedThemeNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -1,75 +1,17 @@
|
||||
import { ThemeEffects } from './theme.effects';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
import { LinkService } from '../../core/cache/builders/link.service';
|
||||
import { cold, hot } from 'jasmine-marbles';
|
||||
import { ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
||||
import { SetThemeAction } from './theme.actions';
|
||||
import { Theme } from '../../../config/theme.model';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { ROUTER_NAVIGATED } from '@ngrx/router-store';
|
||||
import { ResolverActionTypes } from '../../core/resolving/resolver.actions';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { COMMUNITY } from '../../core/shared/community.resource-type';
|
||||
import { NoOpAction } from '../ngrx/no-op.action';
|
||||
import { ITEM } from '../../core/shared/item.resource-type';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { COLLECTION } from '../../core/shared/collection.resource-type';
|
||||
import {
|
||||
createNoContentRemoteDataObject$,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../remote-data.utils';
|
||||
import { BASE_THEME_NAME } from './theme.constants';
|
||||
|
||||
/**
|
||||
* LinkService able to mock recursively resolving DSO parent links
|
||||
* Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until
|
||||
* none are left, after which it returns a no-content remote-date
|
||||
*/
|
||||
class MockLinkService {
|
||||
index = -1;
|
||||
|
||||
constructor(private ancestorDSOs: DSpaceObject[]) {
|
||||
}
|
||||
|
||||
resolveLinkWithoutAttaching() {
|
||||
if (this.index >= this.ancestorDSOs.length - 1) {
|
||||
return createNoContentRemoteDataObject$();
|
||||
} else {
|
||||
this.index++;
|
||||
return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ThemeEffects', () => {
|
||||
let themeEffects: ThemeEffects;
|
||||
let linkService: LinkService;
|
||||
let initialState;
|
||||
|
||||
let ancestorDSOs: DSpaceObject[];
|
||||
|
||||
function init() {
|
||||
ancestorDSOs = [
|
||||
Object.assign(new Collection(), {
|
||||
type: COLLECTION.value,
|
||||
uuid: 'collection-uuid',
|
||||
_links: { owningCommunity: { href: 'owning-community-link' } }
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'sub-community-uuid',
|
||||
_links: { parentCommunity: { href: 'parent-community-link' } }
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'top-community-uuid',
|
||||
}),
|
||||
];
|
||||
linkService = new MockLinkService(ancestorDSOs) as any;
|
||||
initialState = {
|
||||
theme: {
|
||||
currentTheme: 'custom',
|
||||
@@ -82,7 +24,6 @@ describe('ThemeEffects', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ThemeEffects,
|
||||
{ provide: LinkService, useValue: linkService },
|
||||
provideMockStore({ initialState }),
|
||||
provideMockActions(() => mockActions)
|
||||
]
|
||||
@@ -110,205 +51,4 @@ describe('ThemeEffects', () => {
|
||||
expect(themeEffects.initTheme$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateThemeOnRouteChange$', () => {
|
||||
const url = '/test/route';
|
||||
const dso = Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: '0958c910-2037-42a9-81c7-dca80e3892b4',
|
||||
});
|
||||
|
||||
function spyOnPrivateMethods() {
|
||||
spyOn((themeEffects as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso]));
|
||||
spyOn((themeEffects as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' }));
|
||||
spyOn((themeEffects as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom'));
|
||||
}
|
||||
|
||||
describe('when a resolved action is present', () => {
|
||||
beforeEach(() => {
|
||||
setupEffectsWithActions(
|
||||
hot('--ab-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
b: {
|
||||
type: ResolverActionTypes.RESOLVED,
|
||||
payload: { url, dso },
|
||||
}
|
||||
})
|
||||
);
|
||||
spyOnPrivateMethods();
|
||||
});
|
||||
|
||||
it('should set the theme it receives from the DSO', () => {
|
||||
const expected = cold('--b-', {
|
||||
b: new SetThemeAction('custom')
|
||||
});
|
||||
|
||||
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no resolved action is present', () => {
|
||||
beforeEach(() => {
|
||||
setupEffectsWithActions(
|
||||
hot('--a-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
})
|
||||
);
|
||||
spyOnPrivateMethods();
|
||||
});
|
||||
|
||||
it('should set the theme it receives from the route url', () => {
|
||||
const expected = cold('--b-', {
|
||||
b: new SetThemeAction('custom')
|
||||
});
|
||||
|
||||
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no themes are present', () => {
|
||||
beforeEach(() => {
|
||||
setupEffectsWithActions(
|
||||
hot('--a-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
})
|
||||
);
|
||||
(themeEffects as any).themes = [];
|
||||
});
|
||||
|
||||
it('should return an empty action', () => {
|
||||
const expected = cold('--b-', {
|
||||
b: new NoOpAction()
|
||||
});
|
||||
|
||||
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private functions', () => {
|
||||
beforeEach(() => {
|
||||
setupEffectsWithActions(hot('-', {}));
|
||||
});
|
||||
|
||||
describe('getActionForMatch', () => {
|
||||
it('should return a SET action if the new theme differs from the current theme', () => {
|
||||
const theme = new Theme({ name: 'new-theme' });
|
||||
expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme'));
|
||||
});
|
||||
|
||||
it('should return an empty action if the new theme equals the current theme', () => {
|
||||
const theme = new Theme({ name: 'old-theme' });
|
||||
expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction());
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchThemeToDSOs', () => {
|
||||
let themes: Theme[];
|
||||
let nonMatchingTheme: Theme;
|
||||
let itemMatchingTheme: Theme;
|
||||
let communityMatchingTheme: Theme;
|
||||
let dsos: DSpaceObject[];
|
||||
|
||||
beforeEach(() => {
|
||||
nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), {
|
||||
matches: () => false
|
||||
});
|
||||
itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), {
|
||||
matches: (url, dso) => (dso as any).type === ITEM.value
|
||||
});
|
||||
communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), {
|
||||
matches: (url, dso) => (dso as any).type === COMMUNITY.value
|
||||
});
|
||||
dsos = [
|
||||
Object.assign(new Item(), {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
}),
|
||||
Object.assign(new Collection(), {
|
||||
type: COLLECTION.value,
|
||||
uuid: 'collection-uuid',
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'community-uuid',
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
describe('when no themes match any of the DSOs', () => {
|
||||
beforeEach(() => {
|
||||
themes = [ nonMatchingTheme ];
|
||||
themeEffects.themes = themes;
|
||||
});
|
||||
|
||||
it('should return undefined', () => {
|
||||
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when one of the themes match a DSOs', () => {
|
||||
beforeEach(() => {
|
||||
themes = [ nonMatchingTheme, itemMatchingTheme ];
|
||||
themeEffects.themes = themes;
|
||||
});
|
||||
|
||||
it('should return the matching theme', () => {
|
||||
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when multiple themes match some of the DSOs', () => {
|
||||
it('should return the first matching theme', () => {
|
||||
themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ];
|
||||
themeEffects.themes = themes;
|
||||
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
|
||||
|
||||
themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ];
|
||||
themeEffects.themes = themes;
|
||||
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAncestorDSOs', () => {
|
||||
it('should return an array of the provided DSO and its ancestors', (done) => {
|
||||
const dso = Object.assign(new Item(), {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
_links: { owningCollection: { href: 'owning-collection-link' } },
|
||||
});
|
||||
|
||||
observableOf(dso).pipe(
|
||||
(themeEffects as any).getAncestorDSOs()
|
||||
).subscribe((result) => {
|
||||
expect(result).toEqual([dso, ...ancestorDSOs]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => {
|
||||
const dso = {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
};
|
||||
|
||||
observableOf(dso).pipe(
|
||||
(themeEffects as any).getAncestorDSOs()
|
||||
).subscribe((result) => {
|
||||
expect(result).toEqual([dso]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,22 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
||||
import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store';
|
||||
import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { SetThemeAction } from './theme.actions';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model';
|
||||
import { hasValue, isNotEmpty, hasNoValue } from '../empty.util';
|
||||
import { NoOpAction } from '../ngrx/no-op.action';
|
||||
import { Store, select } from '@ngrx/store';
|
||||
import { ThemeState } from './theme.reducer';
|
||||
import { currentThemeSelector } from './theme.service';
|
||||
import { of as observableOf, EMPTY, Observable } from 'rxjs';
|
||||
import { ResolverActionTypes, ResolvedAction } from '../../core/resolving/resolver.actions';
|
||||
import { followLink } from '../utils/follow-link-config.model';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { LinkService } from '../../core/cache/builders/link.service';
|
||||
import { hasValue, hasNoValue } from '../empty.util';
|
||||
import { BASE_THEME_NAME } from './theme.constants';
|
||||
|
||||
export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =>
|
||||
@@ -27,16 +14,6 @@ export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =
|
||||
|
||||
@Injectable()
|
||||
export class ThemeEffects {
|
||||
/**
|
||||
* The list of configured themes
|
||||
*/
|
||||
themes: Theme[];
|
||||
|
||||
/**
|
||||
* True if at least one theme depends on the route
|
||||
*/
|
||||
hasDynamicTheme: boolean;
|
||||
|
||||
/**
|
||||
* Initialize with a theme that doesn't depend on the route.
|
||||
*/
|
||||
@@ -53,133 +30,8 @@ export class ThemeEffects {
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* An effect that fires when a route change completes,
|
||||
* and determines whether or not the theme should change
|
||||
*/
|
||||
updateThemeOnRouteChange$ = createEffect(() => this.actions$.pipe(
|
||||
// Listen for when a route change ends
|
||||
ofType(ROUTER_NAVIGATED),
|
||||
withLatestFrom(
|
||||
// Pull in the latest resolved action, or undefined if none was dispatched yet
|
||||
this.actions$.pipe(ofType(ResolverActionTypes.RESOLVED), startWith(undefined)),
|
||||
// and the current theme from the store
|
||||
this.store.pipe(select(currentThemeSelector))
|
||||
),
|
||||
switchMap(([navigatedAction, resolvedAction, currentTheme]: [RouterNavigatedAction, ResolvedAction, string]) => {
|
||||
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
|
||||
const currentRouteUrl = navigatedAction.payload.routerState.url;
|
||||
// If resolvedAction exists, and deals with the current url
|
||||
if (hasValue(resolvedAction) && resolvedAction.payload.url === currentRouteUrl) {
|
||||
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
|
||||
return observableOf(resolvedAction.payload.dso).pipe(
|
||||
this.getAncestorDSOs(),
|
||||
map((dsos: DSpaceObject[]) => {
|
||||
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
|
||||
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)];
|
||||
}
|
||||
|
||||
// If there are no themes configured, do nothing
|
||||
return [new NoOpAction()];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* return the action to dispatch based on the given matching theme
|
||||
*
|
||||
* @param newTheme The theme to create an action for
|
||||
* @param currentThemeName The name of the currently active theme
|
||||
* @private
|
||||
*/
|
||||
private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction {
|
||||
if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) {
|
||||
// If we have a match, and it isn't already the active theme, set it as the new theme
|
||||
return new SetThemeAction(newTheme.config.name);
|
||||
} else {
|
||||
// Otherwise, do nothing
|
||||
return new NoOpAction();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the given DSpaceObjects in order to see if they match the configured themes in order.
|
||||
* If a match is found, the matching theme is returned
|
||||
*
|
||||
* @param dsos The DSpaceObjects to check
|
||||
* @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);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as
|
||||
* input. The initial DSpaceObject will be the first element of the output array, followed by
|
||||
* its parent, its grandparent etc
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private getAncestorDSOs() {
|
||||
return (source: Observable<DSpaceObject>): Observable<DSpaceObject[]> =>
|
||||
source.pipe(
|
||||
expand((dso: DSpaceObject) => {
|
||||
// Check if the dso exists and has a parent link
|
||||
if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') {
|
||||
const linkName = (dso as any).getParentLinkKey();
|
||||
// If it does, retrieve it.
|
||||
return this.linkService.resolveLinkWithoutAttaching<DSpaceObject, DSpaceObject>(dso, followLink(linkName)).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map((rd: RemoteData<DSpaceObject>) => {
|
||||
if (hasValue(rd.payload)) {
|
||||
// If there's a parent, use it for the next iteration
|
||||
return rd.payload;
|
||||
} else {
|
||||
// If there's no parent, or an error, return null, which will stop recursion
|
||||
// in the next iteration
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The current dso has no value, or no parent. Return EMPTY to stop recursion
|
||||
return EMPTY;
|
||||
}),
|
||||
// only allow through DSOs that have a value
|
||||
filter((dso: DSpaceObject) => hasValue(dso)),
|
||||
// Wait for recursion to complete, and emit all results at once, in an array
|
||||
toArray()
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private store: Store<ThemeState>,
|
||||
private linkService: LinkService,
|
||||
) {
|
||||
// Create objects from the theme configs in the environment file
|
||||
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig));
|
||||
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
|
||||
hasValue(themeConfig.regex) ||
|
||||
hasValue(themeConfig.handle) ||
|
||||
hasValue(themeConfig.uuid)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
370
src/app/shared/theme-support/theme.service.spec.ts
Normal file
370
src/app/shared/theme-support/theme.service.spec.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
import { LinkService } from '../../core/cache/builders/link.service';
|
||||
import { cold, hot } from 'jasmine-marbles';
|
||||
import { SetThemeAction } from './theme.actions';
|
||||
import { Theme } from '../../../config/theme.model';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { COMMUNITY } from '../../core/shared/community.resource-type';
|
||||
import { NoOpAction } from '../ngrx/no-op.action';
|
||||
import { ITEM } from '../../core/shared/item.resource-type';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { COLLECTION } from '../../core/shared/collection.resource-type';
|
||||
import {
|
||||
createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../remote-data.utils';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { ROUTER_NAVIGATED } from '@ngrx/router-store';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
|
||||
/**
|
||||
* LinkService able to mock recursively resolving DSO parent links
|
||||
* Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until
|
||||
* none are left, after which it returns a no-content remote-date
|
||||
*/
|
||||
class MockLinkService {
|
||||
index = -1;
|
||||
|
||||
constructor(private ancestorDSOs: DSpaceObject[]) {
|
||||
}
|
||||
|
||||
resolveLinkWithoutAttaching() {
|
||||
if (this.index >= this.ancestorDSOs.length - 1) {
|
||||
return createNoContentRemoteDataObject$();
|
||||
} else {
|
||||
this.index++;
|
||||
return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ThemeService', () => {
|
||||
let themeService: ThemeService;
|
||||
let linkService: LinkService;
|
||||
let initialState;
|
||||
|
||||
let ancestorDSOs: DSpaceObject[];
|
||||
|
||||
const mockCommunity = Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'top-community-uuid',
|
||||
});
|
||||
|
||||
function init() {
|
||||
ancestorDSOs = [
|
||||
Object.assign(new Collection(), {
|
||||
type: COLLECTION.value,
|
||||
uuid: 'collection-uuid',
|
||||
_links: { owningCommunity: { href: 'owning-community-link' } }
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'sub-community-uuid',
|
||||
_links: { parentCommunity: { href: 'parent-community-link' } }
|
||||
}),
|
||||
mockCommunity,
|
||||
];
|
||||
linkService = new MockLinkService(ancestorDSOs) as any;
|
||||
initialState = {
|
||||
theme: {
|
||||
currentTheme: 'custom',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupServiceWithActions(mockActions) {
|
||||
init();
|
||||
const mockDsoService = {
|
||||
findById: () => createSuccessfulRemoteDataObject$(mockCommunity)
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ThemeService,
|
||||
{ provide: LinkService, useValue: linkService },
|
||||
provideMockStore({ initialState }),
|
||||
provideMockActions(() => mockActions),
|
||||
{ provide: DSpaceObjectDataService, useValue: mockDsoService }
|
||||
]
|
||||
});
|
||||
|
||||
themeService = TestBed.inject(ThemeService);
|
||||
spyOn((themeService as any).store, 'dispatch').and.stub();
|
||||
}
|
||||
|
||||
describe('updateThemeOnRouteChange$', () => {
|
||||
const url = '/test/route';
|
||||
const dso = Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: '0958c910-2037-42a9-81c7-dca80e3892b4',
|
||||
});
|
||||
|
||||
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), 'getActionForMatch').and.returnValue(new SetThemeAction('custom'));
|
||||
}
|
||||
|
||||
describe('when no resolved action is present', () => {
|
||||
beforeEach(() => {
|
||||
setupServiceWithActions(
|
||||
hot('--a-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
})
|
||||
);
|
||||
spyOnPrivateMethods();
|
||||
});
|
||||
|
||||
it('should set the theme it receives from the route url', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe(() => {
|
||||
expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe((result) => {
|
||||
expect(result).toEqual(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no themes are present', () => {
|
||||
beforeEach(() => {
|
||||
setupServiceWithActions(
|
||||
hot('--a-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
})
|
||||
);
|
||||
(themeService as any).themes = [];
|
||||
});
|
||||
|
||||
it('should not dispatch any action', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe(() => {
|
||||
expect((themeService as any).store.dispatch).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe((result) => {
|
||||
expect(result).toEqual(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a dso is present in the snapshot\'s data', () => {
|
||||
let snapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
setupServiceWithActions(
|
||||
hot('--a-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
})
|
||||
);
|
||||
spyOnPrivateMethods();
|
||||
snapshot = Object.assign({
|
||||
data: {
|
||||
dso: createSuccessfulRemoteDataObject(dso)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should match the theme to the dso', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => {
|
||||
expect((themeService as any).matchThemeToDSOs).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the theme it receives from the data dso', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => {
|
||||
expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe((result) => {
|
||||
expect(result).toEqual(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a scope is present in the snapshot\'s parameters', () => {
|
||||
let snapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
setupServiceWithActions(
|
||||
hot('--a-', {
|
||||
a: {
|
||||
type: ROUTER_NAVIGATED,
|
||||
payload: { routerState: { url } },
|
||||
},
|
||||
})
|
||||
);
|
||||
spyOnPrivateMethods();
|
||||
snapshot = Object.assign({
|
||||
queryParams: {
|
||||
scope: mockCommunity.uuid
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should match the theme to the dso found through the scope', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => {
|
||||
expect((themeService as any).matchThemeToDSOs).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the theme it receives from the dso found through the scope', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => {
|
||||
expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true', (done) => {
|
||||
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe((result) => {
|
||||
expect(result).toEqual(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private functions', () => {
|
||||
beforeEach(() => {
|
||||
setupServiceWithActions(hot('-', {}));
|
||||
});
|
||||
|
||||
describe('getActionForMatch', () => {
|
||||
it('should return a SET action if the new theme differs from the current theme', () => {
|
||||
const theme = new Theme({ name: 'new-theme' });
|
||||
expect((themeService as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme'));
|
||||
});
|
||||
|
||||
it('should return an empty action if the new theme equals the current theme', () => {
|
||||
const theme = new Theme({ name: 'old-theme' });
|
||||
expect((themeService as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction());
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchThemeToDSOs', () => {
|
||||
let themes: Theme[];
|
||||
let nonMatchingTheme: Theme;
|
||||
let itemMatchingTheme: Theme;
|
||||
let communityMatchingTheme: Theme;
|
||||
let dsos: DSpaceObject[];
|
||||
|
||||
beforeEach(() => {
|
||||
nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), {
|
||||
matches: () => false
|
||||
});
|
||||
itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), {
|
||||
matches: (url, dso) => (dso as any).type === ITEM.value
|
||||
});
|
||||
communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), {
|
||||
matches: (url, dso) => (dso as any).type === COMMUNITY.value
|
||||
});
|
||||
dsos = [
|
||||
Object.assign(new Item(), {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
}),
|
||||
Object.assign(new Collection(), {
|
||||
type: COLLECTION.value,
|
||||
uuid: 'collection-uuid',
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
type: COMMUNITY.value,
|
||||
uuid: 'community-uuid',
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
describe('when no themes match any of the DSOs', () => {
|
||||
beforeEach(() => {
|
||||
themes = [ nonMatchingTheme ];
|
||||
themeService.themes = themes;
|
||||
});
|
||||
|
||||
it('should return undefined', () => {
|
||||
expect((themeService as any).matchThemeToDSOs(dsos, '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when one of the themes match a DSOs', () => {
|
||||
beforeEach(() => {
|
||||
themes = [ nonMatchingTheme, itemMatchingTheme ];
|
||||
themeService.themes = themes;
|
||||
});
|
||||
|
||||
it('should return the matching theme', () => {
|
||||
expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when multiple themes match some of the DSOs', () => {
|
||||
it('should return the first matching theme', () => {
|
||||
themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ];
|
||||
themeService.themes = themes;
|
||||
expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
|
||||
|
||||
themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ];
|
||||
themeService.themes = themes;
|
||||
expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAncestorDSOs', () => {
|
||||
it('should return an array of the provided DSO and its ancestors', (done) => {
|
||||
const dso = Object.assign(new Item(), {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
_links: { owningCollection: { href: 'owning-collection-link' } },
|
||||
});
|
||||
|
||||
observableOf(dso).pipe(
|
||||
(themeService as any).getAncestorDSOs()
|
||||
).subscribe((result) => {
|
||||
expect(result).toEqual([dso, ...ancestorDSOs]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => {
|
||||
const dso = {
|
||||
type: ITEM.value,
|
||||
uuid: 'item-uuid',
|
||||
};
|
||||
|
||||
observableOf(dso).pipe(
|
||||
(themeService as any).getAncestorDSOs()
|
||||
).subscribe((result) => {
|
||||
expect(result).toEqual([dso]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,10 +1,26 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, Inject } from '@angular/core';
|
||||
import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { ThemeState } from './theme.reducer';
|
||||
import { SetThemeAction } from './theme.actions';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { hasValue } from '../empty.util';
|
||||
import { SetThemeAction, ThemeActionTypes } from './theme.actions';
|
||||
import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators';
|
||||
import { 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 { EMPTY, of as observableOf } from 'rxjs';
|
||||
import { Theme, ThemeConfig, themeFactory } from '../../../config/theme.model';
|
||||
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';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator';
|
||||
|
||||
export const themeStateSelector = createFeatureSelector<ThemeState>('theme');
|
||||
|
||||
@@ -17,9 +33,29 @@ export const currentThemeSelector = createSelector(
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ThemeService {
|
||||
/**
|
||||
* The list of configured themes
|
||||
*/
|
||||
themes: Theme[];
|
||||
|
||||
/**
|
||||
* True if at least one theme depends on the route
|
||||
*/
|
||||
hasDynamicTheme: boolean;
|
||||
|
||||
constructor(
|
||||
private store: Store<ThemeState>,
|
||||
private linkService: LinkService,
|
||||
private dSpaceObjectDataService: DSpaceObjectDataService,
|
||||
@Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig
|
||||
) {
|
||||
// Create objects from the theme configs in the environment file
|
||||
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig));
|
||||
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
|
||||
hasValue(themeConfig.regex) ||
|
||||
hasValue(themeConfig.handle) ||
|
||||
hasValue(themeConfig.uuid)
|
||||
);
|
||||
}
|
||||
|
||||
setTheme(newName: string) {
|
||||
@@ -43,4 +79,174 @@ export class ThemeService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the theme needs to change depending on the current route's URL and snapshot data
|
||||
* If the snapshot contains a dso, this will be used to match a theme
|
||||
* If the snapshot contains a scope parameters, this will be used to match a theme
|
||||
* Otherwise the URL is matched against
|
||||
* If none of the above find a match, the theme doesn't change
|
||||
* @param currentRouteUrl
|
||||
* @param activatedRouteSnapshot
|
||||
* @return Observable boolean emitting whether or not the theme has been changed
|
||||
*/
|
||||
updateThemeOnRouteChange$(currentRouteUrl: string, activatedRouteSnapshot: ActivatedRouteSnapshot): Observable<boolean> {
|
||||
// and the current theme from the store
|
||||
const currentTheme$: Observable<string> = this.store.pipe(select(currentThemeSelector));
|
||||
|
||||
const action$ = currentTheme$.pipe(
|
||||
switchMap((currentTheme: string) => {
|
||||
const snapshotWithData = this.findRouteData(activatedRouteSnapshot);
|
||||
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
|
||||
if (hasValue(snapshotWithData) && hasValue(snapshotWithData.data) && hasValue(snapshotWithData.data.dso)) {
|
||||
const dsoRD: RemoteData<DSpaceObject> = snapshotWithData.data.dso;
|
||||
if (dsoRD.hasSucceeded) {
|
||||
// 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);
|
||||
return this.getActionForMatch(dsoMatch, currentTheme);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (hasValue(activatedRouteSnapshot.queryParams) && hasValue(activatedRouteSnapshot.queryParams.scope)) {
|
||||
const dsoFromScope$: Observable<RemoteData<DSpaceObject>> = this.dSpaceObjectDataService.findById(activatedRouteSnapshot.queryParams.scope);
|
||||
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
|
||||
return dsoFromScope$.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
this.getAncestorDSOs(),
|
||||
map((dsos: DSpaceObject[]) => {
|
||||
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
|
||||
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)];
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
this.store.dispatch(action);
|
||||
});
|
||||
|
||||
return action$.pipe(
|
||||
map((action) => action.type === ThemeActionTypes.SET),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a DSpaceObject in one of the provided route snapshots their data
|
||||
* Recursively looks for the dso in the routes their child routes until it reaches a dead end or finds one
|
||||
* @param routes
|
||||
*/
|
||||
findRouteData(...routes: ActivatedRouteSnapshot[]) {
|
||||
const result = routes.find((route) => hasValue(route.data) && hasValue(route.data.dso));
|
||||
if (hasValue(result)) {
|
||||
return result;
|
||||
} else {
|
||||
const nextLevelRoutes = routes
|
||||
.map((route: ActivatedRouteSnapshot) => route.children)
|
||||
.reduce((combined: ActivatedRouteSnapshot[], current: ActivatedRouteSnapshot[]) => [...combined, ...current]);
|
||||
if (isNotEmpty(nextLevelRoutes)) {
|
||||
return this.findRouteData(...nextLevelRoutes);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as
|
||||
* input. The initial DSpaceObject will be the first element of the output array, followed by
|
||||
* its parent, its grandparent etc
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private getAncestorDSOs() {
|
||||
return (source: Observable<DSpaceObject>): Observable<DSpaceObject[]> =>
|
||||
source.pipe(
|
||||
expand((dso: DSpaceObject) => {
|
||||
// Check if the dso exists and has a parent link
|
||||
if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') {
|
||||
const linkName = (dso as any).getParentLinkKey();
|
||||
// If it does, retrieve it.
|
||||
return this.linkService.resolveLinkWithoutAttaching<DSpaceObject, DSpaceObject>(dso, followLink(linkName)).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map((rd: RemoteData<DSpaceObject>) => {
|
||||
if (hasValue(rd.payload)) {
|
||||
// If there's a parent, use it for the next iteration
|
||||
return rd.payload;
|
||||
} else {
|
||||
// If there's no parent, or an error, return null, which will stop recursion
|
||||
// in the next iteration
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The current dso has no value, or no parent. Return EMPTY to stop recursion
|
||||
return EMPTY;
|
||||
}),
|
||||
// only allow through DSOs that have a value
|
||||
filter((dso: DSpaceObject) => hasValue(dso)),
|
||||
// Wait for recursion to complete, and emit all results at once, in an array
|
||||
toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* return the action to dispatch based on the given matching theme
|
||||
*
|
||||
* @param newTheme The theme to create an action for
|
||||
* @param currentThemeName The name of the currently active theme
|
||||
* @private
|
||||
*/
|
||||
private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction {
|
||||
if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) {
|
||||
// If we have a match, and it isn't already the active theme, set it as the new theme
|
||||
return new SetThemeAction(newTheme.config.name);
|
||||
} else {
|
||||
// Otherwise, do nothing
|
||||
return new NoOpAction();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the given DSpaceObjects in order to see if they match the configured themes in order.
|
||||
* If a match is found, the matching theme is returned
|
||||
*
|
||||
* @param dsos The DSpaceObjects to check
|
||||
* @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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a ThemeConfig by its name;
|
||||
*/
|
||||
getThemeConfigFor(themeName: string): ThemeConfig {
|
||||
return this.gtcf(themeName);
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,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';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@Component({
|
||||
@@ -32,8 +33,8 @@ describe('ThemedComponent', () => {
|
||||
let fixture: ComponentFixture<TestThemedComponent>;
|
||||
let themeService: ThemeService;
|
||||
|
||||
function setupTestingModuleForTheme(theme: string) {
|
||||
themeService = getMockThemeService(theme);
|
||||
function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) {
|
||||
themeService = getMockThemeService(theme, themes);
|
||||
TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
declarations: [TestThemedComponent, VarDirective],
|
||||
@@ -44,17 +45,20 @@ describe('ThemedComponent', () => {
|
||||
}).compileComponents();
|
||||
}
|
||||
|
||||
function initComponent() {
|
||||
fixture = TestBed.createComponent(TestThemedComponent);
|
||||
component = fixture.componentInstance;
|
||||
spyOn(component as any, 'importThemedComponent').and.callThrough();
|
||||
component.testInput = 'changed';
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
describe('when the current theme matches a themed component', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('custom');
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(TestThemedComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.testInput = 'changed';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
@@ -70,16 +74,12 @@ describe('ThemedComponent', () => {
|
||||
});
|
||||
|
||||
describe('when the current theme doesn\'t match a themed component', () => {
|
||||
describe('and it doesn\'t extend another theme', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('non-existing-theme');
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(TestThemedComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.testInput = 'changed';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
@@ -93,5 +93,108 @@ describe('ThemedComponent', () => {
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('and it extends another theme', () => {
|
||||
describe('that doesn\'t match it either', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('current-theme', [
|
||||
{ name: 'current-theme', extends: 'non-existing-theme' },
|
||||
]);
|
||||
}));
|
||||
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme');
|
||||
expect((component as any).compRef.instance.type).toEqual('default');
|
||||
});
|
||||
}));
|
||||
|
||||
it('should sync up this component\'s input with the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('that does match it', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('current-theme', [
|
||||
{ name: 'current-theme', extends: 'custom' },
|
||||
]);
|
||||
}));
|
||||
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom');
|
||||
expect((component as any).compRef.instance.type).toEqual('themed');
|
||||
});
|
||||
}));
|
||||
|
||||
it('should sync up this component\'s input with the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('that extends another theme that doesn\'t match it either', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('current-theme', [
|
||||
{ name: 'current-theme', extends: 'parent-theme' },
|
||||
{ name: 'parent-theme', extends: 'non-existing-theme' },
|
||||
]);
|
||||
}));
|
||||
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme');
|
||||
expect((component as any).compRef.instance.type).toEqual('default');
|
||||
});
|
||||
}));
|
||||
|
||||
it('should sync up this component\'s input with the default component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('that extends another theme that does match it', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
setupTestingModuleForTheme('current-theme', [
|
||||
{ name: 'current-theme', extends: 'parent-theme' },
|
||||
{ name: 'parent-theme', extends: 'custom' },
|
||||
]);
|
||||
}));
|
||||
|
||||
beforeEach(initComponent);
|
||||
|
||||
it('should set compRef to the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme');
|
||||
expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom');
|
||||
expect((component as any).compRef.instance.type).toEqual('themed');
|
||||
});
|
||||
}));
|
||||
|
||||
it('should sync up this component\'s input with the themed component', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
@@ -11,7 +11,7 @@ import {
|
||||
OnChanges
|
||||
} from '@angular/core';
|
||||
import { hasValue, isNotEmpty } from '../empty.util';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Observable, of as observableOf, Subscription } from 'rxjs';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { fromPromise } from 'rxjs/internal-compatibility';
|
||||
import { catchError, switchMap, map } from 'rxjs/operators';
|
||||
@@ -69,11 +69,7 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
|
||||
this.lazyLoadSub.unsubscribe();
|
||||
}
|
||||
|
||||
this.lazyLoadSub =
|
||||
fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe(
|
||||
// if there is no themed version of the component an exception is thrown,
|
||||
// catch it and return null instead
|
||||
catchError(() => [null]),
|
||||
this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe(
|
||||
switchMap((themedFile: any) => {
|
||||
if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
|
||||
// if the file is not null, and exports a component with the specified name,
|
||||
@@ -113,4 +109,32 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to import this component from the current theme or a theme it {@link NamedThemeConfig.extends}.
|
||||
* Recurse until we succeed or when until we run out of themes to fall back to.
|
||||
*
|
||||
* @param themeName The name of the theme to check
|
||||
* @param checkedThemeNames The list of theme names that are already checked
|
||||
* @private
|
||||
*/
|
||||
private resolveThemedComponent(themeName?: string, checkedThemeNames: string[] = []): Observable<any> {
|
||||
if (isNotEmpty(themeName)) {
|
||||
return fromPromise(this.importThemedComponent(themeName)).pipe(
|
||||
catchError(() => {
|
||||
// Try the next ancestor theme instead
|
||||
const nextTheme = this.themeService.getThemeConfigFor(themeName)?.extends;
|
||||
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
|
||||
if (checkedThemeNames.includes(nextTheme)) {
|
||||
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
|
||||
} else {
|
||||
return this.resolveThemedComponent(nextTheme, nextCheckedThemeNames);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// If we got here, we've failed to import this component from any ancestor theme → fall back to unthemed
|
||||
return observableOf(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -895,6 +895,30 @@
|
||||
"collection.select.table.title": "Title",
|
||||
|
||||
|
||||
"collection.source.controls.head": "Harvest Controls",
|
||||
"collection.source.controls.test.submit.error": "Something went wrong with initiating the testing of the settings",
|
||||
"collection.source.controls.test.failed": "The script to test the settings has failed",
|
||||
"collection.source.controls.test.completed": "The script to test the settings has successfully finished",
|
||||
"collection.source.controls.test.submit": "Test configuration",
|
||||
"collection.source.controls.test.running": "Testing configuration...",
|
||||
"collection.source.controls.import.submit.success": "The import has been successfully initiated",
|
||||
"collection.source.controls.import.submit.error": "Something went wrong with initiating the import",
|
||||
"collection.source.controls.import.submit": "Import now",
|
||||
"collection.source.controls.import.running": "Importing...",
|
||||
"collection.source.controls.import.failed": "An error occurred during the import",
|
||||
"collection.source.controls.import.completed": "The import completed",
|
||||
"collection.source.controls.reset.submit.success": "The reset and reimport has been successfully initiated",
|
||||
"collection.source.controls.reset.submit.error": "Something went wrong with initiating the reset and reimport",
|
||||
"collection.source.controls.reset.failed": "An error occurred during the reset and reimport",
|
||||
"collection.source.controls.reset.completed": "The reset and reimport completed",
|
||||
"collection.source.controls.reset.submit": "Reset and reimport",
|
||||
"collection.source.controls.reset.running": "Resetting and reimporting...",
|
||||
"collection.source.controls.harvest.status": "Harvest status:",
|
||||
"collection.source.controls.harvest.start": "Harvest start time:",
|
||||
"collection.source.controls.harvest.last": "Last time harvested:",
|
||||
"collection.source.controls.harvest.message": "Harvest info:",
|
||||
"collection.source.controls.harvest.no-information": "N/A",
|
||||
|
||||
|
||||
"collection.source.update.notifications.error.content": "The provided settings have been tested and didn't work.",
|
||||
|
||||
@@ -1874,6 +1898,10 @@
|
||||
|
||||
"item.page.collections": "Collections",
|
||||
|
||||
"item.page.collections.loading": "Loading...",
|
||||
|
||||
"item.page.collections.load-more": "Load more",
|
||||
|
||||
"item.page.date": "Date",
|
||||
|
||||
"item.page.edit": "Edit this item",
|
||||
|
@@ -6,6 +6,12 @@ import { getDSORoute } from '../app/app-routing-paths';
|
||||
// tslint:disable:max-classes-per-file
|
||||
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;
|
||||
}
|
||||
|
||||
export interface RegExThemeConfig extends NamedThemeConfig {
|
||||
|
@@ -265,6 +265,19 @@ export const environment: GlobalConfig = {
|
||||
// uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
|
||||
// },
|
||||
// {
|
||||
// // The extends property specifies an ancestor theme (by name). Whenever a themed component is not found
|
||||
// // in the current theme, its ancestor theme(s) will be checked recursively before falling back to default.
|
||||
// name: 'custom-A',
|
||||
// extends: 'custom-B',
|
||||
// // Any of the matching properties above can be used
|
||||
// handle: '10673/34',
|
||||
// },
|
||||
// {
|
||||
// name: 'custom-B',
|
||||
// extends: 'custom',
|
||||
// handle: '10673/12',
|
||||
// },
|
||||
// {
|
||||
// // A theme with only a name will match every route
|
||||
// name: 'custom'
|
||||
// },
|
||||
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { slideSidebarPadding } from '../../../../../../../app/shared/animations/slide';
|
||||
import { FileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-page-file-section',
|
||||
// templateUrl: './file-section.component.html',
|
||||
templateUrl: '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component.html',
|
||||
animations: [slideSidebarPadding],
|
||||
})
|
||||
export class FileSectionComponent extends BaseComponent {
|
||||
|
||||
}
|
@@ -79,8 +79,10 @@ import { HeaderComponent } from './app/header/header.component';
|
||||
import { FooterComponent } from './app/footer/footer.component';
|
||||
import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component';
|
||||
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component';
|
||||
import { FileSectionComponent} from './app/item-page/simple/field-components/file-section/file-section.component';
|
||||
|
||||
const DECLARATIONS = [
|
||||
FileSectionComponent,
|
||||
HomePageComponent,
|
||||
HomeNewsComponent,
|
||||
RootComponent,
|
||||
|
Reference in New Issue
Block a user