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
|
||||||
|
|
||||||
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.
|
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
|
### 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.
|
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
|
Documentation
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
@@ -9,7 +9,13 @@ import {
|
|||||||
Optional,
|
Optional,
|
||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
} from '@angular/core';
|
} 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 { BehaviorSubject, Observable, of } from 'rxjs';
|
||||||
import { select, Store } from '@ngrx/store';
|
import { select, Store } from '@ngrx/store';
|
||||||
@@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
*/
|
*/
|
||||||
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the idle modal is is currently open
|
* 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) => {
|
this.themeService.getThemeName$().subscribe((themeName: string) => {
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
// the theme css will never download server side, so this should only happen on the browser
|
// 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)) {
|
if (hasValue(themeName)) {
|
||||||
this.setThemeCss(themeName);
|
this.setThemeCss(themeName);
|
||||||
@@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
this.router.events.pipe(
|
let resolveEndFound = false;
|
||||||
// This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component
|
this.router.events.subscribe((event) => {
|
||||||
// More information on this bug-fix: https://blog.angular-university.io/angular-debugging/
|
|
||||||
delay(0)
|
|
||||||
).subscribe((event) => {
|
|
||||||
if (event instanceof NavigationStart) {
|
if (event instanceof NavigationStart) {
|
||||||
|
resolveEndFound = false;
|
||||||
this.isRouteLoading$.next(true);
|
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 (
|
} else if (
|
||||||
event instanceof NavigationEnd ||
|
event instanceof NavigationEnd ||
|
||||||
event instanceof NavigationCancel
|
event instanceof NavigationCancel
|
||||||
) {
|
) {
|
||||||
|
if (!resolveEndFound) {
|
||||||
|
this.isThemeLoading$.next(false);
|
||||||
|
}
|
||||||
this.isRouteLoading$.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.
|
// the fact that this callback is used, proves we're on the browser.
|
||||||
this.isThemeLoading$.next(false);
|
this.isThemeCSSLoading$.next(false);
|
||||||
};
|
};
|
||||||
head.appendChild(link);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -1,57 +1,74 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="d-inline-block float-right">
|
<div class="d-inline-block float-right">
|
||||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||||
[disabled]="!(hasChanges() | async)"
|
[disabled]="!(hasChanges() | async)"
|
||||||
(click)="discard()"><i
|
(click)="discard()"><i
|
||||||
class="fas fa-times"></i>
|
class="fas fa-times"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||||
(click)="reinstate()"><i
|
(click)="reinstate()"><i
|
||||||
class="fas fa-undo-alt"></i>
|
class="fas fa-undo-alt"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
<button class="btn btn-primary"
|
||||||
(click)="onSubmit()"><i
|
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||||
class="fas fa-save"></i>
|
(click)="onSubmit()"><i
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
class="fas fa-save"></i>
|
||||||
</button>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||||
</div>
|
</button>
|
||||||
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
</div>
|
||||||
<div *ngIf="contentSource" class="form-check mb-4">
|
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
||||||
<input type="checkbox" class="form-check-input" id="externalSourceCheck" [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
<div *ngIf="contentSource" class="form-check mb-4">
|
||||||
<label class="form-check-label" for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
<input type="checkbox" class="form-check-input" id="externalSourceCheck"
|
||||||
</div>
|
[checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
||||||
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
|
<label class="form-check-label"
|
||||||
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
|
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>
|
||||||
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
<div class="row">
|
||||||
[formId]="'collection-source-form-id'"
|
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
||||||
[formGroup]="formGroup"
|
[formId]="'collection-source-form-id'"
|
||||||
[formModel]="formModel"
|
[formGroup]="formGroup"
|
||||||
[formLayout]="formLayout"
|
[formModel]="formModel"
|
||||||
[displaySubmit]="false"
|
[formLayout]="formLayout"
|
||||||
[displayCancel]="false"
|
[displaySubmit]="false"
|
||||||
(dfChange)="onChange($event)"
|
[displayCancel]="false"
|
||||||
(submitForm)="onSubmit()"
|
(dfChange)="onChange($event)"
|
||||||
(cancel)="onCancel()"></ds-form>
|
(submitForm)="onSubmit()"
|
||||||
<div class="container-fluid" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
|
(cancel)="onCancel()"></ds-form>
|
||||||
<div class="d-inline-block float-right">
|
|
||||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
|
||||||
[disabled]="!(hasChanges() | async)"
|
|
||||||
(click)="discard()"><i
|
|
||||||
class="fas fa-times"></i>
|
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
|
||||||
(click)="reinstate()"><i
|
|
||||||
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)"
|
|
||||||
(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 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
|
||||||
|
class="fas fa-times"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||||
|
(click)="reinstate()"><i
|
||||||
|
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)"
|
||||||
|
(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',
|
label: 'DSpace Intermediate Metadata',
|
||||||
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
_links: { self: { href: 'contentsource-selflink' } }
|
||||||
});
|
});
|
||||||
fieldUpdate = {
|
fieldUpdate = {
|
||||||
field: contentSource,
|
field: contentSource,
|
||||||
@@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => {
|
|||||||
updateContentSource: observableOf(contentSource),
|
updateContentSource: observableOf(contentSource),
|
||||||
getHarvesterEndpoint: observableOf('harvester-endpoint')
|
getHarvesterEndpoint: observableOf('harvester-endpoint')
|
||||||
});
|
});
|
||||||
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']);
|
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||||
|
@@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem
|
|||||||
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
|
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
|
||||||
take(1)
|
take(1)
|
||||||
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
|
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
|
||||||
|
this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href);
|
||||||
// Update harvester
|
// Update harvester
|
||||||
this.collectionRD$.pipe(
|
this.collectionRD$.pipe(
|
||||||
getFirstSucceededRemoteData(),
|
getFirstSucceededRemoteData(),
|
||||||
|
@@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
|
|||||||
import { CollectionSourceComponent } from './collection-source/collection-source.component';
|
import { CollectionSourceComponent } from './collection-source/collection-source.component';
|
||||||
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
|
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
|
||||||
import { CollectionFormModule } from '../collection-form/collection-form.module';
|
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
|
* 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,
|
CollectionRolesComponent,
|
||||||
CollectionCurateComponent,
|
CollectionCurateComponent,
|
||||||
CollectionSourceComponent,
|
CollectionSourceComponent,
|
||||||
|
|
||||||
|
CollectionSourceControlsComponent,
|
||||||
CollectionAuthorizationsComponent
|
CollectionAuthorizationsComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@@ -138,7 +138,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
* Get the collection's content harvester
|
* Get the collection's content harvester
|
||||||
* @param collectionId
|
* @param collectionId
|
||||||
*/
|
*/
|
||||||
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> {
|
getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
|
||||||
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
|
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
|
||||||
isNotEmptyOperator(),
|
isNotEmptyOperator(),
|
||||||
take(1)
|
take(1)
|
||||||
@@ -146,7 +146,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
|
|
||||||
href$.subscribe((href: string) => {
|
href$.subscribe((href: string) => {
|
||||||
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
|
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
|
||||||
this.requestService.send(request, true);
|
this.requestService.send(request, useCachedVersionIfAvailable);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.rdbService.buildSingle<ContentSource>(href$);
|
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
|
* @param item Item we want the owning collection of
|
||||||
*/
|
*/
|
||||||
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
|
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
|
||||||
return this.findByHref(item._links.owningCollection.href);
|
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 { HALLink } from './hal-link.model';
|
||||||
import { MetadataConfig } from './metadata-config.model';
|
import { MetadataConfig } from './metadata-config.model';
|
||||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
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 { CONTENT_SOURCE } from './content-source.resource-type';
|
||||||
import { excludeFromEquals } from '../utilities/equals.decorators';
|
import { excludeFromEquals } from '../utilities/equals.decorators';
|
||||||
import { ResourceType } from './resource-type';
|
import { ResourceType } from './resource-type';
|
||||||
|
import { ContentSourceSetSerializer } from './content-source-set-serializer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type of content harvesting used
|
* The type of content harvesting used
|
||||||
@@ -49,7 +50,8 @@ export class ContentSource extends CacheableObject {
|
|||||||
/**
|
/**
|
||||||
* OAI Specific set ID
|
* OAI Specific set ID
|
||||||
*/
|
*/
|
||||||
@autoserializeAs('oai_set_id')
|
@deserializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
|
||||||
|
@serializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
|
||||||
oaiSetId: string;
|
oaiSetId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +72,30 @@ export class ContentSource extends CacheableObject {
|
|||||||
*/
|
*/
|
||||||
metadataConfigs: MetadataConfig[];
|
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
|
* 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">
|
<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>
|
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</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 { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
|
||||||
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||||
import { CollectionsComponent } from './collections.component';
|
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;
|
const createMockCollection = (id: string) => Object.assign(new Collection(), {
|
||||||
let fixture: ComponentFixture<CollectionsComponent>;
|
id: id,
|
||||||
|
name: `collection-${id}`,
|
||||||
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 succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)});
|
const mockItem: Item = new Item();
|
||||||
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)});
|
|
||||||
|
|
||||||
describe('CollectionsComponent', () => {
|
describe('CollectionsComponent', () => {
|
||||||
collectionDataServiceStub = {
|
let collectionDataService;
|
||||||
findOwningCollectionFor(item: Item) {
|
|
||||||
if (item === succeededMockItem) {
|
let mockCollection1: Collection;
|
||||||
return createSuccessfulRemoteDataObject$(mockCollection1);
|
let mockCollection2: Collection;
|
||||||
} else {
|
let mockCollection3: Collection;
|
||||||
return createFailedRemoteDataObject$('error', 500);
|
let mockCollection4: Collection;
|
||||||
}
|
|
||||||
}
|
let component: CollectionsComponent;
|
||||||
};
|
let fixture: ComponentFixture<CollectionsComponent>;
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
|
collectionDataService = jasmine.createSpyObj([
|
||||||
|
'findOwningCollectionFor',
|
||||||
|
'findMappedCollectionsFor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockCollection1 = createMockCollection('c1');
|
||||||
|
mockCollection2 = createMockCollection('c2');
|
||||||
|
mockCollection3 = createMockCollection('c3');
|
||||||
|
mockCollection4 = createMockCollection('c4');
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot()],
|
imports: [TranslateModule.forRoot()],
|
||||||
declarations: [ CollectionsComponent ],
|
declarations: [ CollectionsComponent ],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
|
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
|
||||||
{ provide: CollectionDataService, useValue: collectionDataServiceStub },
|
{ provide: CollectionDataService, useValue: collectionDataService },
|
||||||
],
|
],
|
||||||
|
|
||||||
schemas: [ NO_ERRORS_SCHEMA ]
|
schemas: [ NO_ERRORS_SCHEMA ]
|
||||||
@@ -59,33 +58,264 @@ describe('CollectionsComponent', () => {
|
|||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
fixture = TestBed.createComponent(CollectionsComponent);
|
fixture = TestBed.createComponent(CollectionsComponent);
|
||||||
collectionsComponent = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
collectionsComponent.label = 'test.test';
|
component.item = mockItem;
|
||||||
collectionsComponent.separator = '<br/>';
|
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(() => {
|
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();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show the collection', () => {
|
it('should display the owning collection', () => {
|
||||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||||
expect(collectionField).not.toBeNull();
|
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(() => {
|
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();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show the collection', () => {
|
it('should display the owning collection and the mapped collection', () => {
|
||||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||||
expect(collectionField).toBeNull();
|
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 { Component, Input, OnInit } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators';
|
||||||
import { CollectionDataService } from '../../../core/data/collection-data.service';
|
import { CollectionDataService } from '../../../core/data/collection-data.service';
|
||||||
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model';
|
import { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||||
import { RemoteData } from '../../../core/data/remote-data';
|
|
||||||
|
|
||||||
import { Collection } from '../../../core/shared/collection.model';
|
import { Collection } from '../../../core/shared/collection.model';
|
||||||
import { Item } from '../../../core/shared/item.model';
|
import { Item } from '../../../core/shared/item.model';
|
||||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
|
||||||
import { hasValue } from '../../../shared/empty.util';
|
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
|
* This component renders the parent collections section of the item
|
||||||
@@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit {
|
|||||||
|
|
||||||
separator = '<br/>';
|
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) {
|
constructor(private cds: CollectionDataService) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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
|
const mappedCollections$: Observable<Collection[]> = this.loadMore$.pipe(
|
||||||
// for an Item aren't returned by the REST API yet,
|
// update isLoading$
|
||||||
// only the owning collection
|
tap(() => this.isLoading$.next(true)),
|
||||||
this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe(
|
|
||||||
map((rd: RemoteData<Collection>) => {
|
// request next batch of mapped collections
|
||||||
if (hasValue(rd.payload)) {
|
withLatestFrom(this.lastPage$),
|
||||||
return new RemoteData(
|
switchMap(([_, lastPage]: [void, number]) => {
|
||||||
rd.timeCompleted,
|
return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), {
|
||||||
rd.msToLive,
|
elementsPerPage: this.pageSize,
|
||||||
rd.lastUpdated,
|
currentPage: lastPage + 1,
|
||||||
rd.state,
|
}));
|
||||||
rd.errorMessage,
|
}),
|
||||||
buildPaginatedList({
|
|
||||||
elementsPerPage: 10,
|
getAllCompletedRemoteData<PaginatedList<Collection>>(),
|
||||||
totalPages: 1,
|
|
||||||
currentPage: 1,
|
// update isLoading$
|
||||||
totalElements: 1,
|
tap(() => this.isLoading$.next(false)),
|
||||||
_links: {
|
|
||||||
self: rd.payload._links.self
|
getAllSucceededRemoteDataPayload(),
|
||||||
}
|
|
||||||
} as PageInfo, [rd.payload]),
|
// update hasMore$
|
||||||
rd.statusCode
|
tap((response: PaginatedList<Collection>) => this.hasMore$.next(response.currentPage < response.totalPages)),
|
||||||
);
|
|
||||||
} else {
|
// update lastPage$
|
||||||
return rd as any;
|
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 { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
|
||||||
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
|
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
|
||||||
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
|
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
|
||||||
|
import { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component';
|
||||||
|
|
||||||
|
|
||||||
const ENTRY_COMPONENTS = [
|
const ENTRY_COMPONENTS = [
|
||||||
// put only entry components that use custom decorator
|
// put only entry components that use custom decorator
|
||||||
@@ -39,6 +41,7 @@ const ENTRY_COMPONENTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const DECLARATIONS = [
|
const DECLARATIONS = [
|
||||||
|
ThemedFileSectionComponent,
|
||||||
ItemPageComponent,
|
ItemPageComponent,
|
||||||
ThemedItemPageComponent,
|
ThemedItemPageComponent,
|
||||||
FullItemPageComponent,
|
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">
|
<ng-container *ngIf="mediaViewer.image">
|
||||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||||
</ng-container>
|
</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-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||||
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||||
[parentItem]="object"
|
[parentItem]="object"
|
||||||
|
@@ -25,7 +25,7 @@
|
|||||||
<ng-container *ngIf="mediaViewer.image">
|
<ng-container *ngIf="mediaViewer.image">
|
||||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||||
</ng-container>
|
</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-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||||
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||||
[parentItem]="object"
|
[parentItem]="object"
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
<div class="nav-item dropdown expandable-navbar-section"
|
<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)"
|
(mouseenter)="activateSection($event)"
|
||||||
(mouseleave)="deactivateSection($event)">
|
(mouseleave)="deactivateSection($event)">
|
||||||
<a href="#" class="nav-link dropdown-toggle" routerLinkActive="active"
|
<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 { MenuService } from '../../shared/menu/menu.service';
|
||||||
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
|
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
|
||||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
import { VarDirective } from '../../shared/utils/var.directive';
|
||||||
|
|
||||||
describe('ExpandableNavbarSectionComponent', () => {
|
describe('ExpandableNavbarSectionComponent', () => {
|
||||||
let component: ExpandableNavbarSectionComponent;
|
let component: ExpandableNavbarSectionComponent;
|
||||||
@@ -19,7 +20,7 @@ describe('ExpandableNavbarSectionComponent', () => {
|
|||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [NoopAnimationsModule],
|
imports: [NoopAnimationsModule],
|
||||||
declarations: [ExpandableNavbarSectionComponent, TestComponent],
|
declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: 'sectionDataProvider', useValue: {} },
|
{ provide: 'sectionDataProvider', useValue: {} },
|
||||||
{ provide: MenuService, useValue: menuService },
|
{ 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', () => {
|
describe('when a click occurs on the section header', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(menuService, 'toggleActiveSection');
|
spyOn(menuService, 'toggleActiveSection');
|
||||||
@@ -96,7 +169,7 @@ describe('ExpandableNavbarSectionComponent', () => {
|
|||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [NoopAnimationsModule],
|
imports: [NoopAnimationsModule],
|
||||||
declarations: [ExpandableNavbarSectionComponent, TestComponent],
|
declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: 'sectionDataProvider', useValue: {} },
|
{ provide: 'sectionDataProvider', useValue: {} },
|
||||||
{ provide: MenuService, useValue: menuService },
|
{ provide: MenuService, useValue: menuService },
|
||||||
|
@@ -21,11 +21,14 @@ import { storeModuleConfig } from '../../app.reducer';
|
|||||||
import { FindListOptions } from '../../core/data/request.models';
|
import { FindListOptions } from '../../core/data/request.models';
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { PaginationServiceStub } from '../testing/pagination-service.stub';
|
import { PaginationServiceStub } from '../testing/pagination-service.stub';
|
||||||
|
import { ThemeService } from '../theme-support/theme.service';
|
||||||
|
|
||||||
describe('BrowseByComponent', () => {
|
describe('BrowseByComponent', () => {
|
||||||
let comp: BrowseByComponent;
|
let comp: BrowseByComponent;
|
||||||
let fixture: ComponentFixture<BrowseByComponent>;
|
let fixture: ComponentFixture<BrowseByComponent>;
|
||||||
|
|
||||||
|
let themeService: ThemeService;
|
||||||
|
|
||||||
const mockItems = [
|
const mockItems = [
|
||||||
Object.assign(new Item(), {
|
Object.assign(new Item(), {
|
||||||
id: 'fakeId-1',
|
id: 'fakeId-1',
|
||||||
@@ -57,6 +60,9 @@ describe('BrowseByComponent', () => {
|
|||||||
const paginationService = new PaginationServiceStub(paginationConfig);
|
const paginationService = new PaginationServiceStub(paginationConfig);
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
|
themeService = jasmine.createSpyObj('themeService', {
|
||||||
|
getThemeName: 'dspace',
|
||||||
|
});
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -75,7 +81,8 @@ describe('BrowseByComponent', () => {
|
|||||||
],
|
],
|
||||||
declarations: [],
|
declarations: [],
|
||||||
providers: [
|
providers: [
|
||||||
{provide: PaginationService, useValue: paginationService}
|
{provide: PaginationService, useValue: paginationService},
|
||||||
|
{ provide: ThemeService, useValue: themeService },
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
@@ -7,12 +7,17 @@ import {
|
|||||||
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
|
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
|
||||||
import { Context } from '../../core/shared/context.model';
|
import { Context } from '../../core/shared/context.model';
|
||||||
import * as uuidv4 from 'uuid/v4';
|
import * as uuidv4 from 'uuid/v4';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
let ogEnvironmentThemes;
|
||||||
|
|
||||||
describe('MetadataRepresentation decorator function', () => {
|
describe('MetadataRepresentation decorator function', () => {
|
||||||
const type1 = 'TestType';
|
const type1 = 'TestType';
|
||||||
const type2 = 'TestType2';
|
const type2 = 'TestType2';
|
||||||
const type3 = 'TestType3';
|
const type3 = 'TestType3';
|
||||||
const type4 = 'RandomType';
|
const type4 = 'RandomType';
|
||||||
|
const typeAncestor = 'TestTypeAncestor';
|
||||||
|
const typeUnthemed = 'TestTypeUnthemed';
|
||||||
let prefix;
|
let prefix;
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
@@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => {
|
|||||||
class Test3ItemSubmission {
|
class Test3ItemSubmission {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TestAncestorComponent {
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestUnthemedComponent {
|
||||||
|
}
|
||||||
|
|
||||||
/* tslint:enable:max-classes-per-file */
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -46,8 +57,18 @@ describe('MetadataRepresentation decorator function', () => {
|
|||||||
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
|
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
|
||||||
|
|
||||||
metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission);
|
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', () => {
|
describe('If there\'s an exact match', () => {
|
||||||
it('should return the matching class', () => {
|
it('should return the matching class', () => {
|
||||||
const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace);
|
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 { Context } from '../../core/shared/context.model';
|
||||||
import { InjectionToken } from '@angular/core';
|
import { InjectionToken } from '@angular/core';
|
||||||
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
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', {
|
export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -13,8 +17,6 @@ export const map = new Map();
|
|||||||
|
|
||||||
export const DEFAULT_ENTITY_TYPE = 'Publication';
|
export const DEFAULT_ENTITY_TYPE = 'Publication';
|
||||||
export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText;
|
export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText;
|
||||||
export const DEFAULT_CONTEXT = Context.Any;
|
|
||||||
export const DEFAULT_THEME = '*';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorator function to store metadata representation mapping
|
* Decorator function to store metadata representation mapping
|
||||||
@@ -57,8 +59,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese
|
|||||||
if (hasValue(entityAndMDRepMap)) {
|
if (hasValue(entityAndMDRepMap)) {
|
||||||
const contextMap = entityAndMDRepMap.get(context);
|
const contextMap = entityAndMDRepMap.get(context);
|
||||||
if (hasValue(contextMap)) {
|
if (hasValue(contextMap)) {
|
||||||
if (hasValue(contextMap.get(theme))) {
|
const match = resolveTheme(contextMap, theme);
|
||||||
return contextMap.get(theme);
|
if (hasValue(match)) {
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
if (hasValue(contextMap.get(DEFAULT_THEME))) {
|
if (hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||||
return contextMap.get(DEFAULT_THEME);
|
return contextMap.get(DEFAULT_THEME);
|
||||||
|
@@ -1,9 +1,18 @@
|
|||||||
import { ThemeService } from '../theme-support/theme.service';
|
import { ThemeService } from '../theme-support/theme.service';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { ThemeConfig } from '../../../config/theme.model';
|
||||||
|
import { isNotEmpty } from '../empty.util';
|
||||||
|
|
||||||
export function getMockThemeService(themeName = 'base'): ThemeService {
|
export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
|
||||||
return jasmine.createSpyObj('themeService', {
|
const spy = jasmine.createSpyObj('themeService', {
|
||||||
getThemeName: themeName,
|
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 { BrowserModule, By } from '@angular/platform-browser';
|
||||||
import { ChangeDetectorRef, DebugElement } from '@angular/core';
|
import { ChangeDetectorRef, DebugElement } from '@angular/core';
|
||||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
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 { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
|
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
|
||||||
import { storeModuleConfig } from '../../../app.reducer';
|
import { storeModuleConfig } from '../../../app.reducer';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
describe('NotificationComponent', () => {
|
describe('NotificationComponent', () => {
|
||||||
|
|
||||||
@@ -83,6 +84,8 @@ describe('NotificationComponent', () => {
|
|||||||
deContent = fixture.debugElement.query(By.css('.notification-content'));
|
deContent = fixture.debugElement.query(By.css('.notification-content'));
|
||||||
elContent = deContent.nativeElement;
|
elContent = deContent.nativeElement;
|
||||||
elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
|
elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
|
||||||
|
|
||||||
|
spyOn(comp, 'remove');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create component', () => {
|
it('should create component', () => {
|
||||||
@@ -124,4 +127,51 @@ describe('NotificationComponent', () => {
|
|||||||
expect(elContent.innerHTML).toEqual(htmlContent);
|
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 {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
@@ -23,6 +23,7 @@ import { fadeInEnter, fadeInState, fadeOutLeave, fadeOutState } from '../../anim
|
|||||||
import { NotificationAnimationsStatus } from '../models/notification-animations-type';
|
import { NotificationAnimationsStatus } from '../models/notification-animations-type';
|
||||||
import { isNotEmpty } from '../../empty.util';
|
import { isNotEmpty } from '../../empty.util';
|
||||||
import { INotification } from '../models/notification.model';
|
import { INotification } from '../models/notification.model';
|
||||||
|
import { filter, first } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-notification',
|
selector: 'ds-notification',
|
||||||
@@ -47,6 +48,11 @@ export class NotificationComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
@Input() public notification = null as INotification;
|
@Input() public notification = null as INotification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this notification's countdown should be paused
|
||||||
|
*/
|
||||||
|
@Input() public isPaused$: Observable<boolean> = observableOf(false);
|
||||||
|
|
||||||
// Progress bar variables
|
// Progress bar variables
|
||||||
public title: Observable<string>;
|
public title: Observable<string>;
|
||||||
public content: Observable<string>;
|
public content: Observable<string>;
|
||||||
@@ -99,17 +105,21 @@ export class NotificationComponent implements OnInit, OnDestroy {
|
|||||||
private instance = () => {
|
private instance = () => {
|
||||||
this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
|
this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
|
||||||
|
|
||||||
if (this.count++ === this.steps) {
|
this.isPaused$.pipe(
|
||||||
this.remove();
|
filter(paused => !paused),
|
||||||
// this.item.timeoutEnd!.emit();
|
first(),
|
||||||
} else if (!this.stopTime) {
|
).subscribe(() => {
|
||||||
if (this.showProgressBar) {
|
if (this.count++ === this.steps) {
|
||||||
this.progressWidth += 100 / this.steps;
|
this.remove();
|
||||||
}
|
} else if (!this.stopTime) {
|
||||||
|
if (this.showProgressBar) {
|
||||||
|
this.progressWidth += 100 / this.steps;
|
||||||
|
}
|
||||||
|
|
||||||
this.timer = setTimeout(this.instance, (this.speed - this.diff));
|
this.timer = setTimeout(this.instance, (this.speed - this.diff));
|
||||||
}
|
}
|
||||||
this.zone.run(() => this.cdr.detectChanges());
|
this.zone.run(() => this.cdr.detectChanges());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public remove() {
|
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
|
<ds-notification
|
||||||
class="notification"
|
class="notification"
|
||||||
*ngFor="let a of notifications; let i = index"
|
*ngFor="let a of notifications; let i = index"
|
||||||
[notification]="a">
|
[notification]="a" [isPaused$]="isPaused$">
|
||||||
</ds-notification>
|
</ds-notification>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
|
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 { ChangeDetectorRef } from '@angular/core';
|
||||||
|
|
||||||
import { NotificationsService } from '../notifications.service';
|
import { NotificationsService } from '../notifications.service';
|
||||||
@@ -14,6 +14,9 @@ import { NotificationType } from '../models/notification-type';
|
|||||||
import { uniqueId } from 'lodash';
|
import { uniqueId } from 'lodash';
|
||||||
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
|
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
|
||||||
import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
|
import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
|
||||||
|
import { cold } from 'jasmine-marbles';
|
||||||
|
|
||||||
|
export const bools = { f: false, t: true };
|
||||||
|
|
||||||
describe('NotificationsBoardComponent', () => {
|
describe('NotificationsBoardComponent', () => {
|
||||||
let comp: NotificationsBoardComponent;
|
let comp: NotificationsBoardComponent;
|
||||||
@@ -67,6 +70,40 @@ describe('NotificationsBoardComponent', () => {
|
|||||||
|
|
||||||
it('should have two notifications', () => {
|
it('should have two notifications', () => {
|
||||||
expect(comp.notifications.length).toBe(2);
|
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';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { select, Store } from '@ngrx/store';
|
import { select, Store } from '@ngrx/store';
|
||||||
import { Subscription } from 'rxjs';
|
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||||
import { difference } from 'lodash';
|
import { difference } from 'lodash';
|
||||||
|
|
||||||
import { NotificationsService } from '../notifications.service';
|
import { NotificationsService } from '../notifications.service';
|
||||||
@@ -44,6 +44,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
|
|||||||
public rtl = false;
|
public rtl = false;
|
||||||
public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight';
|
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,
|
constructor(private service: NotificationsService,
|
||||||
private store: Store<AppState>,
|
private store: Store<AppState>,
|
||||||
private cdr: ChangeDetectorRef) {
|
private cdr: ChangeDetectorRef) {
|
||||||
@@ -129,7 +134,6 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
if (this.sub) {
|
if (this.sub) {
|
||||||
this.sub.unsubscribe();
|
this.sub.unsubscribe();
|
||||||
|
@@ -11,6 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { Item } from '../../../../core/shared/item.model';
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
import { provideMockStore } from '@ngrx/store/testing';
|
import { provideMockStore } from '@ngrx/store/testing';
|
||||||
|
import { ThemeService } from '../../../theme-support/theme.service';
|
||||||
|
|
||||||
const testType = 'TestType';
|
const testType = 'TestType';
|
||||||
const testContext = Context.Search;
|
const testContext = Context.Search;
|
||||||
@@ -26,12 +27,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
|||||||
let comp: ListableObjectComponentLoaderComponent;
|
let comp: ListableObjectComponentLoaderComponent;
|
||||||
let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>;
|
let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>;
|
||||||
|
|
||||||
|
let themeService: ThemeService;
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
|
themeService = jasmine.createSpyObj('themeService', {
|
||||||
|
getThemeName: 'dspace',
|
||||||
|
});
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot()],
|
imports: [TranslateModule.forRoot()],
|
||||||
declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective],
|
declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective],
|
||||||
schemas: [NO_ERRORS_SCHEMA],
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
providers: [provideMockStore({})]
|
providers: [
|
||||||
|
provideMockStore({}),
|
||||||
|
{ provide: ThemeService, useValue: themeService },
|
||||||
|
]
|
||||||
}).overrideComponent(ListableObjectComponentLoaderComponent, {
|
}).overrideComponent(ListableObjectComponentLoaderComponent, {
|
||||||
set: {
|
set: {
|
||||||
changeDetection: ChangeDetectionStrategy.Default,
|
changeDetection: ChangeDetectionStrategy.Default,
|
||||||
@@ -48,6 +57,7 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
|||||||
comp.viewMode = testViewMode;
|
comp.viewMode = testViewMode;
|
||||||
comp.context = testContext;
|
comp.context = testContext;
|
||||||
spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any);
|
spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any);
|
||||||
|
spyOn(comp as any, 'connectInputsAndOutputs').and.callThrough();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
}));
|
}));
|
||||||
@@ -56,6 +66,10 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
|||||||
it('should call the getListableObjectComponent function with the right types, view mode and context', () => {
|
it('should call the getListableObjectComponent function with the right types, view mode and context', () => {
|
||||||
expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext);
|
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', () => {
|
describe('when the object is an item and viewMode is a list', () => {
|
||||||
@@ -121,20 +135,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
|||||||
let reloadedObject: any;
|
let reloadedObject: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
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);
|
spyOn((comp as any).contentChange, 'emit').and.returnValue(null);
|
||||||
|
|
||||||
listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
|
listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
|
||||||
reloadedObject = 'object';
|
reloadedObject = 'object';
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass it on connectInputsAndOutputs', fakeAsync(() => {
|
it('should re-instantiate the listable component', fakeAsync(() => {
|
||||||
expect((comp as any).connectInputsAndOutputs).not.toHaveBeenCalled();
|
expect((comp as any).instantiateComponent).not.toHaveBeenCalled();
|
||||||
|
|
||||||
(listableComponent as any).reloadedObject.emit(reloadedObject);
|
(listableComponent as any).reloadedObject.emit(reloadedObject);
|
||||||
tick();
|
tick();
|
||||||
|
|
||||||
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
|
expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should re-emit it as a contentChange', fakeAsync(() => {
|
it('should re-emit it as a contentChange', fakeAsync(() => {
|
||||||
|
@@ -184,7 +184,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges
|
|||||||
if (reloadedObject) {
|
if (reloadedObject) {
|
||||||
this.compRef.destroy();
|
this.compRef.destroy();
|
||||||
this.object = reloadedObject;
|
this.object = reloadedObject;
|
||||||
this.connectInputsAndOutputs();
|
this.instantiateComponent(reloadedObject);
|
||||||
this.contentChange.emit(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 { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||||
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
|
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
|
||||||
import { Context } from '../../../../core/shared/context.model';
|
import { Context } from '../../../../core/shared/context.model';
|
||||||
|
import { environment } from '../../../../../environments/environment';
|
||||||
|
|
||||||
|
let ogEnvironmentThemes;
|
||||||
|
|
||||||
describe('ListableObject decorator function', () => {
|
describe('ListableObject decorator function', () => {
|
||||||
const type1 = 'TestType';
|
const type1 = 'TestType';
|
||||||
const type2 = 'TestType2';
|
const type2 = 'TestType2';
|
||||||
const type3 = 'TestType3';
|
const type3 = 'TestType3';
|
||||||
|
const typeAncestor = 'TestTypeAncestor';
|
||||||
|
const typeUnthemed = 'TestTypeUnthemed';
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
class Test1List {
|
class Test1List {
|
||||||
@@ -27,6 +32,12 @@ describe('ListableObject decorator function', () => {
|
|||||||
class Test3DetailedSubmission {
|
class Test3DetailedSubmission {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TestAncestorComponent {
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestUnthemedComponent {
|
||||||
|
}
|
||||||
|
|
||||||
/* tslint:enable:max-classes-per-file */
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,6 +49,16 @@ describe('ListableObject decorator function', () => {
|
|||||||
|
|
||||||
listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
|
listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
|
||||||
listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission);
|
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);
|
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 { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||||
import { Context } from '../../../../core/shared/context.model';
|
import { Context } from '../../../../core/shared/context.model';
|
||||||
import { hasNoValue, hasValue } from '../../../empty.util';
|
import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util';
|
||||||
import {
|
|
||||||
DEFAULT_CONTEXT,
|
|
||||||
DEFAULT_THEME
|
|
||||||
} from '../../../metadata-representation/metadata-representation.decorator';
|
|
||||||
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
|
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
|
||||||
import { ListableObject } from '../listable-object.model';
|
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_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();
|
const map = new Map();
|
||||||
|
|
||||||
@@ -54,8 +63,9 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
|
|||||||
if (hasValue(typeModeMap)) {
|
if (hasValue(typeModeMap)) {
|
||||||
const contextMap = typeModeMap.get(context);
|
const contextMap = typeModeMap.get(context);
|
||||||
if (hasValue(contextMap)) {
|
if (hasValue(contextMap)) {
|
||||||
if (hasValue(contextMap.get(theme))) {
|
const match = resolveTheme(contextMap, theme);
|
||||||
return contextMap.get(theme);
|
if (hasValue(match)) {
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
|
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||||
bestMatchValue = 3;
|
bestMatchValue = 3;
|
||||||
@@ -80,3 +90,35 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
|
|||||||
}
|
}
|
||||||
return bestMatch;
|
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 { ThemeEffects } from './theme.effects';
|
||||||
import { of as observableOf } from 'rxjs';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideMockActions } from '@ngrx/effects/testing';
|
import { provideMockActions } from '@ngrx/effects/testing';
|
||||||
import { LinkService } from '../../core/cache/builders/link.service';
|
|
||||||
import { cold, hot } from 'jasmine-marbles';
|
import { cold, hot } from 'jasmine-marbles';
|
||||||
import { ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
import { ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
||||||
import { SetThemeAction } from './theme.actions';
|
import { SetThemeAction } from './theme.actions';
|
||||||
import { Theme } from '../../../config/theme.model';
|
|
||||||
import { provideMockStore } from '@ngrx/store/testing';
|
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';
|
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', () => {
|
describe('ThemeEffects', () => {
|
||||||
let themeEffects: ThemeEffects;
|
let themeEffects: ThemeEffects;
|
||||||
let linkService: LinkService;
|
|
||||||
let initialState;
|
let initialState;
|
||||||
|
|
||||||
let ancestorDSOs: DSpaceObject[];
|
|
||||||
|
|
||||||
function init() {
|
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 = {
|
initialState = {
|
||||||
theme: {
|
theme: {
|
||||||
currentTheme: 'custom',
|
currentTheme: 'custom',
|
||||||
@@ -82,7 +24,6 @@ describe('ThemeEffects', () => {
|
|||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
ThemeEffects,
|
ThemeEffects,
|
||||||
{ provide: LinkService, useValue: linkService },
|
|
||||||
provideMockStore({ initialState }),
|
provideMockStore({ initialState }),
|
||||||
provideMockActions(() => mockActions)
|
provideMockActions(() => mockActions)
|
||||||
]
|
]
|
||||||
@@ -110,205 +51,4 @@ describe('ThemeEffects', () => {
|
|||||||
expect(themeEffects.initTheme$).toBeObservable(expected);
|
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 { Injectable } from '@angular/core';
|
||||||
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
||||||
import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store';
|
import { map } from 'rxjs/operators';
|
||||||
import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators';
|
|
||||||
import { SetThemeAction } from './theme.actions';
|
import { SetThemeAction } from './theme.actions';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model';
|
import { hasValue, hasNoValue } from '../empty.util';
|
||||||
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 { BASE_THEME_NAME } from './theme.constants';
|
import { BASE_THEME_NAME } from './theme.constants';
|
||||||
|
|
||||||
export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =>
|
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()
|
@Injectable()
|
||||||
export class ThemeEffects {
|
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.
|
* 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(
|
constructor(
|
||||||
private actions$: Actions,
|
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 { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { ThemeState } from './theme.reducer';
|
import { ThemeState } from './theme.reducer';
|
||||||
import { SetThemeAction } from './theme.actions';
|
import { SetThemeAction, ThemeActionTypes } from './theme.actions';
|
||||||
import { take } from 'rxjs/operators';
|
import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators';
|
||||||
import { hasValue } from '../empty.util';
|
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');
|
export const themeStateSelector = createFeatureSelector<ThemeState>('theme');
|
||||||
|
|
||||||
@@ -17,9 +33,29 @@ export const currentThemeSelector = createSelector(
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ThemeService {
|
export class ThemeService {
|
||||||
|
/**
|
||||||
|
* The list of configured themes
|
||||||
|
*/
|
||||||
|
themes: Theme[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if at least one theme depends on the route
|
||||||
|
*/
|
||||||
|
hasDynamicTheme: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private store: Store<ThemeState>,
|
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) {
|
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 { ThemeService } from './theme.service';
|
||||||
import { getMockThemeService } from '../mocks/theme-service.mock';
|
import { getMockThemeService } from '../mocks/theme-service.mock';
|
||||||
import { TestComponent } from './test/test.component.spec';
|
import { TestComponent } from './test/test.component.spec';
|
||||||
|
import { ThemeConfig } from '../../../config/theme.model';
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
@Component({
|
@Component({
|
||||||
@@ -32,8 +33,8 @@ describe('ThemedComponent', () => {
|
|||||||
let fixture: ComponentFixture<TestThemedComponent>;
|
let fixture: ComponentFixture<TestThemedComponent>;
|
||||||
let themeService: ThemeService;
|
let themeService: ThemeService;
|
||||||
|
|
||||||
function setupTestingModuleForTheme(theme: string) {
|
function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) {
|
||||||
themeService = getMockThemeService(theme);
|
themeService = getMockThemeService(theme, themes);
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [],
|
imports: [],
|
||||||
declarations: [TestThemedComponent, VarDirective],
|
declarations: [TestThemedComponent, VarDirective],
|
||||||
@@ -44,17 +45,20 @@ describe('ThemedComponent', () => {
|
|||||||
}).compileComponents();
|
}).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', () => {
|
describe('when the current theme matches a themed component', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
setupTestingModuleForTheme('custom');
|
setupTestingModuleForTheme('custom');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(initComponent);
|
||||||
fixture = TestBed.createComponent(TestThemedComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
component.testInput = 'changed';
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set compRef to the themed component', waitForAsync(() => {
|
it('should set compRef to the themed component', waitForAsync(() => {
|
||||||
fixture.whenStable().then(() => {
|
fixture.whenStable().then(() => {
|
||||||
@@ -70,28 +74,127 @@ describe('ThemedComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when the current theme doesn\'t match a themed component', () => {
|
describe('when the current theme doesn\'t match a themed component', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
describe('and it doesn\'t extend another theme', () => {
|
||||||
setupTestingModuleForTheme('non-existing-theme');
|
beforeEach(waitForAsync(() => {
|
||||||
}));
|
setupTestingModuleForTheme('non-existing-theme');
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(initComponent);
|
||||||
fixture = TestBed.createComponent(TestThemedComponent);
|
|
||||||
component = fixture.componentInstance;
|
it('should set compRef to the default component', waitForAsync(() => {
|
||||||
component.testInput = 'changed';
|
fixture.whenStable().then(() => {
|
||||||
fixture.detectChanges();
|
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');
|
||||||
|
});
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set compRef to the default component', waitForAsync(() => {
|
describe('and it extends another theme', () => {
|
||||||
fixture.whenStable().then(() => {
|
describe('that doesn\'t match it either', () => {
|
||||||
expect((component as any).compRef.instance.type).toEqual('default');
|
beforeEach(waitForAsync(() => {
|
||||||
});
|
setupTestingModuleForTheme('current-theme', [
|
||||||
}));
|
{ name: 'current-theme', extends: 'non-existing-theme' },
|
||||||
|
]);
|
||||||
|
}));
|
||||||
|
|
||||||
it('should sync up this component\'s input with the default component', waitForAsync(() => {
|
beforeEach(initComponent);
|
||||||
fixture.whenStable().then(() => {
|
|
||||||
expect((component as any).compRef.instance.testInput).toEqual('changed');
|
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 */
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
@@ -11,7 +11,7 @@ import {
|
|||||||
OnChanges
|
OnChanges
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { hasValue, isNotEmpty } from '../empty.util';
|
import { hasValue, isNotEmpty } from '../empty.util';
|
||||||
import { Subscription } from 'rxjs';
|
import { Observable, of as observableOf, Subscription } from 'rxjs';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
import { fromPromise } from 'rxjs/internal-compatibility';
|
import { fromPromise } from 'rxjs/internal-compatibility';
|
||||||
import { catchError, switchMap, map } from 'rxjs/operators';
|
import { catchError, switchMap, map } from 'rxjs/operators';
|
||||||
@@ -69,31 +69,27 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
|
|||||||
this.lazyLoadSub.unsubscribe();
|
this.lazyLoadSub.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lazyLoadSub =
|
this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe(
|
||||||
fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe(
|
switchMap((themedFile: any) => {
|
||||||
// if there is no themed version of the component an exception is thrown,
|
if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
|
||||||
// catch it and return null instead
|
// if the file is not null, and exports a component with the specified name,
|
||||||
catchError(() => [null]),
|
// return that component
|
||||||
switchMap((themedFile: any) => {
|
return [themedFile[this.getComponentName()]];
|
||||||
if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
|
} else {
|
||||||
// if the file is not null, and exports a component with the specified name,
|
// otherwise import and return the default component
|
||||||
// return that component
|
return fromPromise(this.importUnthemedComponent()).pipe(
|
||||||
return [themedFile[this.getComponentName()]];
|
map((unthemedFile: any) => {
|
||||||
} else {
|
return unthemedFile[this.getComponentName()];
|
||||||
// otherwise import and return the default component
|
})
|
||||||
return fromPromise(this.importUnthemedComponent()).pipe(
|
);
|
||||||
map((unthemedFile: any) => {
|
}
|
||||||
return unthemedFile[this.getComponentName()];
|
}),
|
||||||
})
|
).subscribe((constructor: GenericConstructor<T>) => {
|
||||||
);
|
const factory = this.resolver.resolveComponentFactory(constructor);
|
||||||
}
|
this.compRef = this.vcr.createComponent(factory);
|
||||||
}),
|
this.connectInputsAndOutputs();
|
||||||
).subscribe((constructor: GenericConstructor<T>) => {
|
this.cdr.markForCheck();
|
||||||
const factory = this.resolver.resolveComponentFactory(constructor);
|
});
|
||||||
this.compRef = this.vcr.createComponent(factory);
|
|
||||||
this.connectInputsAndOutputs();
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected destroyComponentInstance(): void {
|
protected destroyComponentInstance(): void {
|
||||||
@@ -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.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.",
|
"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": "Collections",
|
||||||
|
|
||||||
|
"item.page.collections.loading": "Loading...",
|
||||||
|
|
||||||
|
"item.page.collections.load-more": "Load more",
|
||||||
|
|
||||||
"item.page.date": "Date",
|
"item.page.date": "Date",
|
||||||
|
|
||||||
"item.page.edit": "Edit this item",
|
"item.page.edit": "Edit this item",
|
||||||
|
@@ -6,6 +6,12 @@ import { getDSORoute } from '../app/app-routing-paths';
|
|||||||
// tslint:disable:max-classes-per-file
|
// tslint:disable:max-classes-per-file
|
||||||
export interface NamedThemeConfig extends Config {
|
export interface NamedThemeConfig extends Config {
|
||||||
name: string;
|
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 {
|
export interface RegExThemeConfig extends NamedThemeConfig {
|
||||||
|
@@ -265,6 +265,19 @@ export const environment: GlobalConfig = {
|
|||||||
// uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
|
// 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
|
// // A theme with only a name will match every route
|
||||||
// name: 'custom'
|
// 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 { FooterComponent } from './app/footer/footer.component';
|
||||||
import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component';
|
import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component';
|
||||||
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.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 = [
|
const DECLARATIONS = [
|
||||||
|
FileSectionComponent,
|
||||||
HomePageComponent,
|
HomePageComponent,
|
||||||
HomeNewsComponent,
|
HomeNewsComponent,
|
||||||
RootComponent,
|
RootComponent,
|
||||||
|
Reference in New Issue
Block a user