Merge branch 'main' into iiif-mirador

This commit is contained in:
Michael Spalti
2021-10-14 08:38:13 -07:00
48 changed files with 2415 additions and 661 deletions

View File

@@ -212,13 +212,17 @@ Once you have tested the Pull Request, please add a comment and/or approval to t
### Unit Tests
Unit tests use Karma. You can find the configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
Unit tests use the [Jasmine test framework](https://jasmine.github.io/), and are run via [Karma](https://karma-runner.github.io/).
You can find the Karma configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
The default browser is Google Chrome.
Place your tests in the same location of the application source code files that they test.
Place your tests in the same location of the application source code files that they test, e.g. ending with `*.component.spec.ts`
and run: `yarn run test`
and run: `yarn test`
If you run into odd test errors, see the Angular guide to debugging tests: https://angular.io/guide/test-debugging
### E2E Tests
@@ -258,6 +262,10 @@ _Hint: Creating e2e tests is easiest in an IDE (like Visual Studio), as it can h
More Information: [docs.cypress.io](https://docs.cypress.io/) has great guides & documentation helping you learn more about writing/debugging e2e tests in Cypress.
### Learning how to build tests
See our [DSpace Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide) for more hints/tips.
Documentation
--------------

View File

@@ -1,4 +1,4 @@
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
import {
AfterViewInit,
ChangeDetectionStrategy,
@@ -9,7 +9,13 @@ import {
Optional,
PLATFORM_ID,
} from '@angular/core';
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
import {
ActivatedRouteSnapshot,
NavigationCancel,
NavigationEnd,
NavigationStart, ResolveEnd,
Router,
} from '@angular/router';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { select, Store } from '@ngrx/store';
@@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit {
*/
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* Whether or not the idle modal is is currently open
@@ -105,7 +112,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.themeService.getThemeName$().subscribe((themeName: string) => {
if (isPlatformBrowser(this.platformId)) {
// the theme css will never download server side, so this should only happen on the browser
this.isThemeLoading$.next(true);
this.isThemeCSSLoading$.next(true);
}
if (hasValue(themeName)) {
this.setThemeCss(themeName);
@@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit {
}
ngAfterViewInit() {
this.router.events.pipe(
// This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component
// More information on this bug-fix: https://blog.angular-university.io/angular-debugging/
delay(0)
).subscribe((event) => {
let resolveEndFound = false;
this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
resolveEndFound = false;
this.isRouteLoading$.next(true);
this.isThemeLoading$.next(true);
} else if (event instanceof ResolveEnd) {
resolveEndFound = true;
const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root;
this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe(
switchMap((changed) => {
if (changed) {
return this.isThemeCSSLoading$;
} else {
return [false];
}
})
).subscribe((changed) => {
this.isThemeLoading$.next(changed);
});
} else if (
event instanceof NavigationEnd ||
event instanceof NavigationCancel
) {
if (!resolveEndFound) {
this.isThemeLoading$.next(false);
}
this.isRouteLoading$.next(false);
}
});
@@ -237,7 +260,7 @@ export class AppComponent implements OnInit, AfterViewInit {
});
}
// the fact that this callback is used, proves we're on the browser.
this.isThemeLoading$.next(false);
this.isThemeCSSLoading$.next(false);
};
head.appendChild(link);
}

View File

@@ -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">&nbsp;{{'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">&nbsp;{{'collection.source.controls.reset.running' | translate}}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,3 @@
.spinner-button {
margin-bottom: calc((var(--bs-line-height-base) * 1rem - var(--bs-font-size-base)) / 2);
}

View File

@@ -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();
});
});
});

View File

@@ -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();
}
});
}
}

View File

@@ -11,7 +11,8 @@
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
<button class="btn btn-primary"
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
(click)="onSubmit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
@@ -19,12 +20,15 @@
</div>
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
<div *ngIf="contentSource" class="form-check mb-4">
<input type="checkbox" class="form-check-input" id="externalSourceCheck" [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
<label class="form-check-label" for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
<input type="checkbox" class="form-check-input" id="externalSourceCheck"
[checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
<label class="form-check-label"
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
</div>
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
</div>
<div class="row">
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
[formId]="'collection-source-form-id'"
[formGroup]="formGroup"
@@ -35,8 +39,11 @@
(dfChange)="onChange($event)"
(submitForm)="onSubmit()"
(cancel)="onCancel()"></ds-form>
<div class="container-fluid" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
<div class="d-inline-block float-right">
</div>
<div class="container mt-2" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
<div class="row">
<div class="col-12">
<div class="d-inline-block float-right ml-1">
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
@@ -48,10 +55,20 @@
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
<button class="btn btn-primary"
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
(click)="onSubmit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"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>

View File

@@ -62,7 +62,8 @@ describe('CollectionSourceComponent', () => {
label: 'DSpace Intermediate Metadata',
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
}
]
],
_links: { self: { href: 'contentsource-selflink' } }
});
fieldUpdate = {
field: contentSource,
@@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => {
updateContentSource: observableOf(contentSource),
getHarvesterEndpoint: observableOf('harvester-endpoint')
});
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']);
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],

View File

@@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
take(1)
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href);
// Update harvester
this.collectionRD$.pipe(
getFirstSucceededRemoteData(),

View File

@@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
import { CollectionSourceComponent } from './collection-source/collection-source.component';
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
import { CollectionFormModule } from '../collection-form/collection-form.module';
import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component';
/**
* Module that contains all components related to the Edit Collection page administrator functionality
@@ -26,6 +27,8 @@ import { CollectionFormModule } from '../collection-form/collection-form.module'
CollectionRolesComponent,
CollectionCurateComponent,
CollectionSourceComponent,
CollectionSourceControlsComponent,
CollectionAuthorizationsComponent
]
})

View File

@@ -138,7 +138,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
* Get the collection's content harvester
* @param collectionId
*/
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> {
getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
isNotEmptyOperator(),
take(1)
@@ -146,7 +146,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
href$.subscribe((href: string) => {
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
this.requestService.send(request, true);
this.requestService.send(request, useCachedVersionIfAvailable);
});
return this.rdbService.buildSingle<ContentSource>(href$);
@@ -208,10 +208,20 @@ export class CollectionDataService extends ComColDataService<Collection> {
}
/**
* Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item
* Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item
* @param item Item we want the owning collection of
*/
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
return this.findByHref(item._links.owningCollection.href);
}
/**
* Get a list of mapped collections for the given item.
* @param item Item for which the mapped collections should be retrieved.
* @param findListOptions Pagination and search options.
*/
findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> {
return this.findAllByHref(item._links.mappedCollections.href, findListOptions);
}
}

View 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');
});
});
});

View 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;
}
}

View File

@@ -1,4 +1,4 @@
import { autoserializeAs, deserializeAs, deserialize } from 'cerialize';
import { autoserializeAs, deserialize, deserializeAs, serializeAs } from 'cerialize';
import { HALLink } from './hal-link.model';
import { MetadataConfig } from './metadata-config.model';
import { CacheableObject } from '../cache/object-cache.reducer';
@@ -6,6 +6,7 @@ import { typedObject } from '../cache/builders/build-decorators';
import { CONTENT_SOURCE } from './content-source.resource-type';
import { excludeFromEquals } from '../utilities/equals.decorators';
import { ResourceType } from './resource-type';
import { ContentSourceSetSerializer } from './content-source-set-serializer';
/**
* The type of content harvesting used
@@ -49,7 +50,8 @@ export class ContentSource extends CacheableObject {
/**
* OAI Specific set ID
*/
@autoserializeAs('oai_set_id')
@deserializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
@serializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
oaiSetId: string;
/**
@@ -70,6 +72,30 @@ export class ContentSource extends CacheableObject {
*/
metadataConfigs: MetadataConfig[];
/**
* The current harvest status
*/
@autoserializeAs('harvest_status')
harvestStatus: string;
/**
* The last's harvest start time
*/
@autoserializeAs('harvest_start_time')
harvestStartTime: string;
/**
* When the collection was last harvested
*/
@autoserializeAs('last_harvested')
lastHarvested: string;
/**
* The current harvest message
*/
@autoserializeAs('harvest_message')
message: string;
/**
* The {@link HALLink}s for this ContentSource
*/

View File

@@ -1,7 +1,21 @@
<ds-metadata-field-wrapper *ngIf="(this.collectionsRD$ | async)?.hasSucceeded" [label]="label | translate">
<ds-metadata-field-wrapper [label]="label | translate">
<div class="collections">
<a *ngFor="let collection of (this.collectionsRD$ | async)?.payload?.page; let last=last;" [routerLink]="['/collections', collection.id]">
<a *ngFor="let collection of (this.collections$ | async); let last=last;" [routerLink]="['/collections', collection.id]">
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
</a>
</div>
<div *ngIf="isLoading$ | async">
{{'item.page.collections.loading' | translate}}
</div>
<a
*ngIf="!(isLoading$ | async) && (hasMore$ | async)"
(click)="$event.preventDefault(); handleLoadMore()"
class="load-more-btn btn btn-sm btn-outline-secondary"
role="button"
href="#"
>
{{'item.page.collections.load-more' | translate}}
</a>
</ds-metadata-field-wrapper>

View File

@@ -9,46 +9,45 @@ import { Item } from '../../../core/shared/item.model';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { CollectionsComponent } from './collections.component';
import { FindListOptions } from '../../../core/data/request.models';
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { PageInfo } from '../../../core/shared/page-info.model';
let collectionsComponent: CollectionsComponent;
let fixture: ComponentFixture<CollectionsComponent>;
let collectionDataServiceStub;
const mockCollection1: Collection = Object.assign(new Collection(), {
metadata: {
'dc.description.abstract': [
{
language: 'en_US',
value: 'Short description'
}
]
},
_links: {
self: { href: 'collection-selflink' }
}
const createMockCollection = (id: string) => Object.assign(new Collection(), {
id: id,
name: `collection-${id}`,
});
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)});
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)});
const mockItem: Item = new Item();
describe('CollectionsComponent', () => {
collectionDataServiceStub = {
findOwningCollectionFor(item: Item) {
if (item === succeededMockItem) {
return createSuccessfulRemoteDataObject$(mockCollection1);
} else {
return createFailedRemoteDataObject$('error', 500);
}
}
};
let collectionDataService;
let mockCollection1: Collection;
let mockCollection2: Collection;
let mockCollection3: Collection;
let mockCollection4: Collection;
let component: CollectionsComponent;
let fixture: ComponentFixture<CollectionsComponent>;
beforeEach(waitForAsync(() => {
collectionDataService = jasmine.createSpyObj([
'findOwningCollectionFor',
'findMappedCollectionsFor',
]);
mockCollection1 = createMockCollection('c1');
mockCollection2 = createMockCollection('c2');
mockCollection3 = createMockCollection('c3');
mockCollection4 = createMockCollection('c4');
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ CollectionsComponent ],
providers: [
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
{ provide: CollectionDataService, useValue: collectionDataServiceStub },
{ provide: CollectionDataService, useValue: collectionDataService },
],
schemas: [ NO_ERRORS_SCHEMA ]
@@ -59,33 +58,264 @@ describe('CollectionsComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(CollectionsComponent);
collectionsComponent = fixture.componentInstance;
collectionsComponent.label = 'test.test';
collectionsComponent.separator = '<br/>';
component = fixture.componentInstance;
component.item = mockItem;
component.label = 'test.test';
component.separator = '<br/>';
component.pageSize = 2;
}));
describe('When the requested item request has succeeded', () => {
describe('when the item has only an owning collection', () => {
let mockPage1: PaginatedList<Collection>;
beforeEach(() => {
collectionsComponent.item = succeededMockItem;
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 1,
elementsPerPage: 2,
totalPages: 0,
totalElements: 0,
}), []);
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
fixture.detectChanges();
});
it('should show the collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
expect(collectionField).not.toBeNull();
it('should display the owning collection', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(1);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(component.lastPage$.getValue()).toBe(1);
expect(component.hasMore$.getValue()).toBe(false);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeNull();
});
});
describe('When the requested item request has failed', () => {
describe('when the item has an owning collection and one mapped collection', () => {
let mockPage1: PaginatedList<Collection>;
beforeEach(() => {
collectionsComponent.item = failedMockItem;
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 1,
elementsPerPage: 2,
totalPages: 1,
totalElements: 1,
}), [mockCollection2]);
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
fixture.detectChanges();
});
it('should not show the collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
expect(collectionField).toBeNull();
it('should display the owning collection and the mapped collection', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(2);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
expect(component.lastPage$.getValue()).toBe(1);
expect(component.hasMore$.getValue()).toBe(false);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeNull();
});
});
describe('when the item has an owning collection and multiple mapped collections', () => {
let mockPage1: PaginatedList<Collection>;
let mockPage2: PaginatedList<Collection>;
beforeEach(() => {
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 1,
elementsPerPage: 2,
totalPages: 2,
totalElements: 3,
}), [mockCollection2, mockCollection3]);
mockPage2 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 2,
elementsPerPage: 2,
totalPages: 2,
totalElements: 1,
}), [mockCollection4]);
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
collectionDataService.findMappedCollectionsFor.and.returnValues(
createSuccessfulRemoteDataObject$(mockPage1),
createSuccessfulRemoteDataObject$(mockPage2),
);
fixture.detectChanges();
});
it('should display the owning collection, two mapped collections and a load more button', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(3);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3');
expect(component.lastPage$.getValue()).toBe(1);
expect(component.hasMore$.getValue()).toBe(true);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeTruthy();
});
describe('when the load more button is clicked', () => {
beforeEach(() => {
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
loadMoreBtn.nativeElement.click();
fixture.detectChanges();
});
it('should display the owning collection and three mapped collections', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledTimes(2);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 2,
}));
expect(collectionFields.length).toBe(4);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3');
expect(collectionFields[3].nativeElement.textContent).toEqual('collection-c4');
expect(component.lastPage$.getValue()).toBe(2);
expect(component.hasMore$.getValue()).toBe(false);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeNull();
});
});
});
describe('when the request for the owning collection fails', () => {
let mockPage1: PaginatedList<Collection>;
beforeEach(() => {
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 1,
elementsPerPage: 2,
totalPages: 1,
totalElements: 1,
}), [mockCollection2]);
collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$());
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
fixture.detectChanges();
});
it('should display the mapped collection only', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(1);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c2');
expect(component.lastPage$.getValue()).toBe(1);
expect(component.hasMore$.getValue()).toBe(false);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeNull();
});
});
describe('when the request for the mapped collections fails', () => {
beforeEach(() => {
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$());
fixture.detectChanges();
});
it('should display the owning collection only', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(1);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(component.lastPage$.getValue()).toBe(0);
expect(component.hasMore$.getValue()).toBe(true);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeTruthy();
});
});
describe('when both requests fail', () => {
beforeEach(() => {
collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$());
collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$());
fixture.detectChanges();
});
it('should display no collections', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(0);
expect(component.lastPage$.getValue()).toBe(0);
expect(component.hasMore$.getValue()).toBe(true);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeTruthy();
});
});
});

View File

@@ -1,14 +1,19 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators';
import { CollectionDataService } from '../../../core/data/collection-data.service';
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { Collection } from '../../../core/shared/collection.model';
import { Item } from '../../../core/shared/item.model';
import { PageInfo } from '../../../core/shared/page-info.model';
import { hasValue } from '../../../shared/empty.util';
import { FindListOptions } from '../../../core/data/request.models';
import {
getAllCompletedRemoteData,
getAllSucceededRemoteDataPayload,
getFirstSucceededRemoteDataPayload,
getPaginatedListPayload,
} from '../../../core/shared/operators';
/**
* This component renders the parent collections section of the item
@@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit {
separator = '<br/>';
collectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
/**
* Amount of mapped collections that should be fetched at once.
*/
pageSize = 5;
/**
* Last page of the mapped collections that has been fetched.
*/
lastPage$: BehaviorSubject<number> = new BehaviorSubject<number>(0);
/**
* Push an event to this observable to fetch the next page of mapped collections.
* Because this observable is a behavior subject, the first page will be requested
* immediately after subscription.
*/
loadMore$: BehaviorSubject<void> = new BehaviorSubject(undefined);
/**
* Whether or not a page of mapped collections is currently being loaded.
*/
isLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
/**
* Whether or not more pages of mapped collections are available.
*/
hasMore$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
/**
* All collections that have been retrieved so far. This includes the owning collection,
* as well as any number of pages of mapped collections.
*/
collections$: Observable<Collection[]>;
constructor(private cds: CollectionDataService) {
}
ngOnInit(): void {
// this.collections = this.item.parents.payload;
const owningCollection$: Observable<Collection> = this.cds.findOwningCollectionFor(this.item).pipe(
getFirstSucceededRemoteDataPayload(),
startWith(null as Collection),
);
// TODO: this should use parents, but the collections
// for an Item aren't returned by the REST API yet,
// only the owning collection
this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe(
map((rd: RemoteData<Collection>) => {
if (hasValue(rd.payload)) {
return new RemoteData(
rd.timeCompleted,
rd.msToLive,
rd.lastUpdated,
rd.state,
rd.errorMessage,
buildPaginatedList({
elementsPerPage: 10,
totalPages: 1,
currentPage: 1,
totalElements: 1,
_links: {
self: rd.payload._links.self
}
} as PageInfo, [rd.payload]),
rd.statusCode
);
} else {
return rd as any;
}
})
const mappedCollections$: Observable<Collection[]> = this.loadMore$.pipe(
// update isLoading$
tap(() => this.isLoading$.next(true)),
// request next batch of mapped collections
withLatestFrom(this.lastPage$),
switchMap(([_, lastPage]: [void, number]) => {
return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), {
elementsPerPage: this.pageSize,
currentPage: lastPage + 1,
}));
}),
getAllCompletedRemoteData<PaginatedList<Collection>>(),
// update isLoading$
tap(() => this.isLoading$.next(false)),
getAllSucceededRemoteDataPayload(),
// update hasMore$
tap((response: PaginatedList<Collection>) => this.hasMore$.next(response.currentPage < response.totalPages)),
// update lastPage$
tap((response: PaginatedList<Collection>) => this.lastPage$.next(response.currentPage)),
getPaginatedListPayload<Collection>(),
// add current batch to list of collections
scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []),
startWith([]),
) as Observable<Collection[]>;
this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe(
map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => {
return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection));
}),
);
}
handleLoadMore() {
this.loadMore$.next();
}
}

View File

@@ -31,6 +31,8 @@ import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/med
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
import { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -39,6 +41,7 @@ const ENTRY_COMPONENTS = [
];
const DECLARATIONS = [
ThemedFileSectionComponent,
ItemPageComponent,
ThemedItemPageComponent,
FullItemPageComponent,

View File

@@ -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`);
}
}

View File

@@ -25,7 +25,7 @@
<ng-container *ngIf="mediaViewer.image">
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
</ng-container>
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
[parentItem]="object"

View File

@@ -25,7 +25,7 @@
<ng-container *ngIf="mediaViewer.image">
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
</ng-container>
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
[parentItem]="object"

View File

@@ -1,5 +1,8 @@
<div class="nav-item dropdown expandable-navbar-section"
(keyup.enter)="activateSection($event)"
*ngVar="(active | async) as isActive"
(keyup.enter)="isActive ? deactivateSection($event) : activateSection($event)"
(keyup.space)="isActive ? deactivateSection($event) : activateSection($event)"
(keydown.space)="$event.preventDefault()"
(mouseenter)="activateSection($event)"
(mouseleave)="deactivateSection($event)">
<a href="#" class="nav-link dropdown-toggle" routerLinkActive="active"

View File

@@ -9,6 +9,7 @@ import { HostWindowService } from '../../shared/host-window.service';
import { MenuService } from '../../shared/menu/menu.service';
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { VarDirective } from '../../shared/utils/var.directive';
describe('ExpandableNavbarSectionComponent', () => {
let component: ExpandableNavbarSectionComponent;
@@ -19,7 +20,7 @@ describe('ExpandableNavbarSectionComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
declarations: [ExpandableNavbarSectionComponent, TestComponent],
declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
providers: [
{ provide: 'sectionDataProvider', useValue: {} },
{ provide: MenuService, useValue: menuService },
@@ -76,6 +77,78 @@ describe('ExpandableNavbarSectionComponent', () => {
});
});
describe('when Enter key is pressed on section header (while inactive)', () => {
beforeEach(() => {
spyOn(menuService, 'activateSection');
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
// dispatch the (keyup.enter) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
});
it('should call activateSection on the menuService', () => {
expect(menuService.activateSection).toHaveBeenCalled();
});
});
describe('when Enter key is pressed on section header (while active)', () => {
beforeEach(() => {
spyOn(menuService, 'deactivateSection');
// Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
// dispatch the (keyup.enter) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
});
it('should call deactivateSection on the menuService', () => {
expect(menuService.deactivateSection).toHaveBeenCalled();
});
});
describe('when spacebar is pressed on section header (while inactive)', () => {
beforeEach(() => {
spyOn(menuService, 'activateSection');
// Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
// dispatch the (keyup.space) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
});
it('should call activateSection on the menuService', () => {
expect(menuService.activateSection).toHaveBeenCalled();
});
});
describe('when spacebar is pressed on section header (while active)', () => {
beforeEach(() => {
spyOn(menuService, 'deactivateSection');
// Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property.
spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true));
component.ngOnInit();
fixture.detectChanges();
const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown'));
// dispatch the (keyup.space) action used in our component HTML
sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
});
it('should call deactivateSection on the menuService', () => {
expect(menuService.deactivateSection).toHaveBeenCalled();
});
});
describe('when a click occurs on the section header', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
@@ -96,7 +169,7 @@ describe('ExpandableNavbarSectionComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
declarations: [ExpandableNavbarSectionComponent, TestComponent],
declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
providers: [
{ provide: 'sectionDataProvider', useValue: {} },
{ provide: MenuService, useValue: menuService },

View File

@@ -21,11 +21,14 @@ import { storeModuleConfig } from '../../app.reducer';
import { FindListOptions } from '../../core/data/request.models';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../testing/pagination-service.stub';
import { ThemeService } from '../theme-support/theme.service';
describe('BrowseByComponent', () => {
let comp: BrowseByComponent;
let fixture: ComponentFixture<BrowseByComponent>;
let themeService: ThemeService;
const mockItems = [
Object.assign(new Item(), {
id: 'fakeId-1',
@@ -57,6 +60,9 @@ describe('BrowseByComponent', () => {
const paginationService = new PaginationServiceStub(paginationConfig);
beforeEach(waitForAsync(() => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: 'dspace',
});
TestBed.configureTestingModule({
imports: [
CommonModule,
@@ -75,7 +81,8 @@ describe('BrowseByComponent', () => {
],
declarations: [],
providers: [
{provide: PaginationService, useValue: paginationService}
{provide: PaginationService, useValue: paginationService},
{ provide: ThemeService, useValue: themeService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();

View File

@@ -7,12 +7,17 @@ import {
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
import { Context } from '../../core/shared/context.model';
import * as uuidv4 from 'uuid/v4';
import { environment } from '../../../environments/environment';
let ogEnvironmentThemes;
describe('MetadataRepresentation decorator function', () => {
const type1 = 'TestType';
const type2 = 'TestType2';
const type3 = 'TestType3';
const type4 = 'RandomType';
const typeAncestor = 'TestTypeAncestor';
const typeUnthemed = 'TestTypeUnthemed';
let prefix;
/* tslint:disable:max-classes-per-file */
@@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => {
class Test3ItemSubmission {
}
class TestAncestorComponent {
}
class TestUnthemedComponent {
}
/* tslint:enable:max-classes-per-file */
beforeEach(() => {
@@ -46,8 +57,18 @@ describe('MetadataRepresentation decorator function', () => {
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission);
// Register a metadata representation in the 'ancestor' theme
metadataRepresentationComponent(key + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'ancestor')(TestAncestorComponent);
metadataRepresentationComponent(key + typeUnthemed, MetadataRepresentationType.Item, Context.Any)(TestUnthemedComponent);
ogEnvironmentThemes = environment.themes;
}
afterEach(() => {
environment.themes = ogEnvironmentThemes;
});
describe('If there\'s an exact match', () => {
it('should return the matching class', () => {
const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace);
@@ -76,4 +97,55 @@ describe('MetadataRepresentation decorator function', () => {
});
});
});
describe('With theme extensions', () => {
// We're only interested in the cases that the requested theme doesn't match the requested entityType,
// as the cases where it does are already covered by the tests above
describe('If requested theme has no match', () => {
beforeEach(() => {
environment.themes = [
{
name: 'requested', // Doesn't match any entityType
extends: 'intermediate',
},
{
name: 'intermediate', // Doesn't match any entityType
extends: 'ancestor',
},
{
name: 'ancestor', // Matches typeAncestor, but not typeUnthemed
}
];
});
it('should return component from the first ancestor theme that matches its entityType', () => {
const component = getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'requested');
expect(component).toEqual(TestAncestorComponent);
});
it('should return default component if none of the ancestor themes match its entityType', () => {
const component = getMetadataRepresentationComponent(prefix + typeUnthemed, MetadataRepresentationType.Item, Context.Any, 'requested');
expect(component).toEqual(TestUnthemedComponent);
});
});
describe('If there is a theme extension cycle', () => {
beforeEach(() => {
environment.themes = [
{ name: 'extension-cycle', extends: 'broken1' },
{ name: 'broken1', extends: 'broken2' },
{ name: 'broken2', extends: 'broken3' },
{ name: 'broken3', extends: 'broken1' },
];
});
it('should throw an error', () => {
expect(() => {
getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'extension-cycle');
}).toThrowError(
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
);
});
});
});
});

View File

@@ -3,6 +3,10 @@ import { hasNoValue, hasValue } from '../empty.util';
import { Context } from '../../core/shared/context.model';
import { InjectionToken } from '@angular/core';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import {
resolveTheme,
DEFAULT_THEME, DEFAULT_CONTEXT
} from '../object-collection/shared/listable-object/listable-object.decorator';
export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', {
providedIn: 'root',
@@ -13,8 +17,6 @@ export const map = new Map();
export const DEFAULT_ENTITY_TYPE = 'Publication';
export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText;
export const DEFAULT_CONTEXT = Context.Any;
export const DEFAULT_THEME = '*';
/**
* Decorator function to store metadata representation mapping
@@ -57,8 +59,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese
if (hasValue(entityAndMDRepMap)) {
const contextMap = entityAndMDRepMap.get(context);
if (hasValue(contextMap)) {
if (hasValue(contextMap.get(theme))) {
return contextMap.get(theme);
const match = resolveTheme(contextMap, theme);
if (hasValue(match)) {
return match;
}
if (hasValue(contextMap.get(DEFAULT_THEME))) {
return contextMap.get(DEFAULT_THEME);

View File

@@ -1,9 +1,18 @@
import { ThemeService } from '../theme-support/theme.service';
import { of as observableOf } from 'rxjs';
import { ThemeConfig } from '../../../config/theme.model';
import { isNotEmpty } from '../empty.util';
export function getMockThemeService(themeName = 'base'): ThemeService {
return jasmine.createSpyObj('themeService', {
export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
const spy = jasmine.createSpyObj('themeService', {
getThemeName: themeName,
getThemeName$: observableOf(themeName)
getThemeName$: observableOf(themeName),
getThemeConfigFor: undefined,
});
if (isNotEmpty(themes)) {
spy.getThemeConfigFor.and.callFake((name: string) => themes.find(theme => theme.name === name));
}
return spy;
}

View File

@@ -1,4 +1,4 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { BrowserModule, By } from '@angular/platform-browser';
import { ChangeDetectorRef, DebugElement } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@@ -16,6 +16,7 @@ import { Notification } from '../models/notification.model';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
import { storeModuleConfig } from '../../../app.reducer';
import { BehaviorSubject } from 'rxjs';
describe('NotificationComponent', () => {
@@ -83,6 +84,8 @@ describe('NotificationComponent', () => {
deContent = fixture.debugElement.query(By.css('.notification-content'));
elContent = deContent.nativeElement;
elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
spyOn(comp, 'remove');
});
it('should create component', () => {
@@ -124,4 +127,51 @@ describe('NotificationComponent', () => {
expect(elContent.innerHTML).toEqual(htmlContent);
});
describe('dismiss countdown', () => {
const TIMEOUT = 5000;
let isPaused$: BehaviorSubject<boolean>;
beforeEach(() => {
isPaused$ = new BehaviorSubject<boolean>(false);
comp.isPaused$ = isPaused$;
comp.notification = {
id: '1',
type: NotificationType.Info,
title: 'Notif. title',
content: 'test',
options: Object.assign(
new NotificationOptions(),
{ timeout: TIMEOUT }
),
html: true
};
});
it('should remove notification after timeout', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT);
expect(comp.remove).toHaveBeenCalled();
}));
describe('isPaused$', () => {
it('should pause countdown on true', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT / 2);
isPaused$.next(true);
tick(TIMEOUT);
expect(comp.remove).not.toHaveBeenCalled();
}));
it('should resume paused countdown on false', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT / 4);
isPaused$.next(true);
tick(TIMEOUT / 4);
isPaused$.next(false);
tick(TIMEOUT);
expect(comp.remove).toHaveBeenCalled();
}));
});
});
});

View File

@@ -1,4 +1,4 @@
import {of as observableOf, Observable } from 'rxjs';
import { Observable, of as observableOf } from 'rxjs';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -23,6 +23,7 @@ import { fadeInEnter, fadeInState, fadeOutLeave, fadeOutState } from '../../anim
import { NotificationAnimationsStatus } from '../models/notification-animations-type';
import { isNotEmpty } from '../../empty.util';
import { INotification } from '../models/notification.model';
import { filter, first } from 'rxjs/operators';
@Component({
selector: 'ds-notification',
@@ -47,6 +48,11 @@ export class NotificationComponent implements OnInit, OnDestroy {
@Input() public notification = null as INotification;
/**
* Whether this notification's countdown should be paused
*/
@Input() public isPaused$: Observable<boolean> = observableOf(false);
// Progress bar variables
public title: Observable<string>;
public content: Observable<string>;
@@ -99,9 +105,12 @@ export class NotificationComponent implements OnInit, OnDestroy {
private instance = () => {
this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
this.isPaused$.pipe(
filter(paused => !paused),
first(),
).subscribe(() => {
if (this.count++ === this.steps) {
this.remove();
// this.item.timeoutEnd!.emit();
} else if (!this.stopTime) {
if (this.showProgressBar) {
this.progressWidth += 100 / this.steps;
@@ -110,6 +119,7 @@ export class NotificationComponent implements OnInit, OnDestroy {
this.timer = setTimeout(this.instance, (this.speed - this.diff));
}
this.zone.run(() => this.cdr.detectChanges());
});
}
public remove() {

View File

@@ -1,7 +1,10 @@
<div class="notifications-wrapper position-fixed" [ngClass]="position">
<div class="notifications-wrapper position-fixed"
[ngClass]="position"
(mouseenter)="this.isPaused$.next(true);"
(mouseleave)="this.isPaused$.next(false);">
<ds-notification
class="notification"
*ngFor="let a of notifications; let i = index"
[notification]="a">
[notification]="a" [isPaused$]="isPaused$">
</ds-notification>
</div>

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserModule, By } from '@angular/platform-browser';
import { ChangeDetectorRef } from '@angular/core';
import { NotificationsService } from '../notifications.service';
@@ -14,6 +14,9 @@ import { NotificationType } from '../models/notification-type';
import { uniqueId } from 'lodash';
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
import { cold } from 'jasmine-marbles';
export const bools = { f: false, t: true };
describe('NotificationsBoardComponent', () => {
let comp: NotificationsBoardComponent;
@@ -67,6 +70,40 @@ describe('NotificationsBoardComponent', () => {
it('should have two notifications', () => {
expect(comp.notifications.length).toBe(2);
expect(fixture.debugElement.queryAll(By.css('ds-notification')).length).toBe(2);
});
describe('notification countdown', () => {
let wrapper;
beforeEach(() => {
wrapper = fixture.debugElement.query(By.css('div.notifications-wrapper'));
});
it('should not be paused by default', () => {
expect(comp.isPaused$).toBeObservable(cold('f', bools));
});
it('should pause on mouseenter', () => {
wrapper.triggerEventHandler('mouseenter');
expect(comp.isPaused$).toBeObservable(cold('t', bools));
});
it('should resume on mouseleave', () => {
wrapper.triggerEventHandler('mouseenter');
wrapper.triggerEventHandler('mouseleave');
expect(comp.isPaused$).toBeObservable(cold('f', bools));
});
it('should be passed to all notifications', () => {
fixture.debugElement.queryAll(By.css('ds-notification'))
.map(node => node.componentInstance)
.forEach(notification => {
expect(notification.isPaused$).toEqual(comp.isPaused$);
});
});
});
})

View File

@@ -9,7 +9,7 @@ import {
} from '@angular/core';
import { select, Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { BehaviorSubject, Subscription } from 'rxjs';
import { difference } from 'lodash';
import { NotificationsService } from '../notifications.service';
@@ -44,6 +44,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
public rtl = false;
public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight';
/**
* Whether to pause the dismiss countdown of all notifications on the board
*/
public isPaused$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
constructor(private service: NotificationsService,
private store: Store<AppState>,
private cdr: ChangeDetectorRef) {
@@ -129,7 +134,6 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
}
});
}
ngOnDestroy(): void {
if (this.sub) {
this.sub.unsubscribe();

View File

@@ -11,6 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { Item } from '../../../../core/shared/item.model';
import { provideMockStore } from '@ngrx/store/testing';
import { ThemeService } from '../../../theme-support/theme.service';
const testType = 'TestType';
const testContext = Context.Search;
@@ -26,12 +27,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
let comp: ListableObjectComponentLoaderComponent;
let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>;
let themeService: ThemeService;
beforeEach(waitForAsync(() => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: 'dspace',
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective],
schemas: [NO_ERRORS_SCHEMA],
providers: [provideMockStore({})]
providers: [
provideMockStore({}),
{ provide: ThemeService, useValue: themeService },
]
}).overrideComponent(ListableObjectComponentLoaderComponent, {
set: {
changeDetection: ChangeDetectionStrategy.Default,
@@ -48,6 +57,7 @@ describe('ListableObjectComponentLoaderComponent', () => {
comp.viewMode = testViewMode;
comp.context = testContext;
spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any);
spyOn(comp as any, 'connectInputsAndOutputs').and.callThrough();
fixture.detectChanges();
}));
@@ -56,6 +66,10 @@ describe('ListableObjectComponentLoaderComponent', () => {
it('should call the getListableObjectComponent function with the right types, view mode and context', () => {
expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext);
});
it('should connectInputsAndOutputs of loaded component', () => {
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
});
});
describe('when the object is an item and viewMode is a list', () => {
@@ -121,20 +135,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
let reloadedObject: any;
beforeEach(() => {
spyOn((comp as any), 'connectInputsAndOutputs').and.returnValue(null);
spyOn((comp as any), 'instantiateComponent').and.returnValue(null);
spyOn((comp as any).contentChange, 'emit').and.returnValue(null);
listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
reloadedObject = 'object';
});
it('should pass it on connectInputsAndOutputs', fakeAsync(() => {
expect((comp as any).connectInputsAndOutputs).not.toHaveBeenCalled();
it('should re-instantiate the listable component', fakeAsync(() => {
expect((comp as any).instantiateComponent).not.toHaveBeenCalled();
(listableComponent as any).reloadedObject.emit(reloadedObject);
tick();
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject);
}));
it('should re-emit it as a contentChange', fakeAsync(() => {

View File

@@ -184,7 +184,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges
if (reloadedObject) {
this.compRef.destroy();
this.object = reloadedObject;
this.connectInputsAndOutputs();
this.instantiateComponent(reloadedObject);
this.contentChange.emit(reloadedObject);
}
});

View File

@@ -2,11 +2,16 @@ import { Item } from '../../../../core/shared/item.model';
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
import { Context } from '../../../../core/shared/context.model';
import { environment } from '../../../../../environments/environment';
let ogEnvironmentThemes;
describe('ListableObject decorator function', () => {
const type1 = 'TestType';
const type2 = 'TestType2';
const type3 = 'TestType3';
const typeAncestor = 'TestTypeAncestor';
const typeUnthemed = 'TestTypeUnthemed';
/* tslint:disable:max-classes-per-file */
class Test1List {
@@ -27,6 +32,12 @@ describe('ListableObject decorator function', () => {
class Test3DetailedSubmission {
}
class TestAncestorComponent {
}
class TestUnthemedComponent {
}
/* tslint:enable:max-classes-per-file */
beforeEach(() => {
@@ -38,6 +49,16 @@ describe('ListableObject decorator function', () => {
listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission);
// Register a metadata representation in the 'ancestor' theme
listableObjectComponent(typeAncestor, ViewMode.ListElement, Context.Any, 'ancestor')(TestAncestorComponent);
listableObjectComponent(typeUnthemed, ViewMode.ListElement, Context.Any)(TestUnthemedComponent);
ogEnvironmentThemes = environment.themes;
});
afterEach(() => {
environment.themes = ogEnvironmentThemes;
});
const gridDecorator = listableObjectComponent('Item', ViewMode.GridElement);
@@ -80,4 +101,55 @@ describe('ListableObject decorator function', () => {
});
});
});
describe('With theme extensions', () => {
// We're only interested in the cases that the requested theme doesn't match the requested objectType,
// as the cases where it does are already covered by the tests above
describe('If requested theme has no match', () => {
beforeEach(() => {
environment.themes = [
{
name: 'requested', // Doesn't match any objectType
extends: 'intermediate',
},
{
name: 'intermediate', // Doesn't match any objectType
extends: 'ancestor',
},
{
name: 'ancestor', // Matches typeAncestor, but not typeUnthemed
}
];
});
it('should return component from the first ancestor theme that matches its objectType', () => {
const component = getListableObjectComponent([typeAncestor], ViewMode.ListElement, Context.Any, 'requested');
expect(component).toEqual(TestAncestorComponent);
});
it('should return default component if none of the ancestor themes match its objectType', () => {
const component = getListableObjectComponent([typeUnthemed], ViewMode.ListElement, Context.Any, 'requested');
expect(component).toEqual(TestUnthemedComponent);
});
});
describe('If there is a theme extension cycle', () => {
beforeEach(() => {
environment.themes = [
{ name: 'extension-cycle', extends: 'broken1' },
{ name: 'broken1', extends: 'broken2' },
{ name: 'broken2', extends: 'broken3' },
{ name: 'broken3', extends: 'broken1' },
];
});
it('should throw an error', () => {
expect(() => {
getListableObjectComponent([typeAncestor], ViewMode.ListElement, Context.Any, 'extension-cycle');
}).toThrowError(
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
);
});
});
});
});

View File

@@ -1,14 +1,23 @@
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { Context } from '../../../../core/shared/context.model';
import { hasNoValue, hasValue } from '../../../empty.util';
import {
DEFAULT_CONTEXT,
DEFAULT_THEME
} from '../../../metadata-representation/metadata-representation.decorator';
import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util';
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { ListableObject } from '../listable-object.model';
import { environment } from '../../../../../environments/environment';
import { ThemeConfig } from '../../../../../config/theme.model';
import { InjectionToken } from '@angular/core';
export const DEFAULT_VIEW_MODE = ViewMode.ListElement;
export const DEFAULT_CONTEXT = Context.Any;
export const DEFAULT_THEME = '*';
/**
* Factory to allow us to inject getThemeConfigFor so we can mock it in tests
*/
export const GET_THEME_CONFIG_FOR_FACTORY = new InjectionToken<(str) => ThemeConfig>('getThemeConfigFor', {
providedIn: 'root',
factory: () => getThemeConfigFor
});
const map = new Map();
@@ -54,8 +63,9 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
if (hasValue(typeModeMap)) {
const contextMap = typeModeMap.get(context);
if (hasValue(contextMap)) {
if (hasValue(contextMap.get(theme))) {
return contextMap.get(theme);
const match = resolveTheme(contextMap, theme);
if (hasValue(match)) {
return match;
}
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
bestMatchValue = 3;
@@ -80,3 +90,35 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
}
return bestMatch;
}
/**
* Searches for a ThemeConfig by its name;
*/
export const getThemeConfigFor = (themeName: string): ThemeConfig => {
return environment.themes.find(theme => theme.name === themeName);
};
/**
* Find a match in the given map for the given theme name, taking theme extension into account
*
* @param contextMap A map of theme names to components
* @param themeName The name of the theme to check
* @param checkedThemeNames The list of theme names that are already checked
*/
export const resolveTheme = (contextMap: Map<any, any>, themeName: string, checkedThemeNames: string[] = []): any => {
const match = contextMap.get(themeName);
if (hasValue(match)) {
return match;
} else {
const cfg = getThemeConfigFor(themeName);
if (hasValue(cfg) && isNotEmpty(cfg.extends)) {
const nextTheme = cfg.extends;
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
if (checkedThemeNames.includes(nextTheme)) {
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
} else {
return resolveTheme(contextMap, nextTheme, nextCheckedThemeNames);
}
}
}
};

View File

@@ -1,75 +1,17 @@
import { ThemeEffects } from './theme.effects';
import { of as observableOf } from 'rxjs';
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { LinkService } from '../../core/cache/builders/link.service';
import { cold, hot } from 'jasmine-marbles';
import { ROOT_EFFECTS_INIT } from '@ngrx/effects';
import { SetThemeAction } from './theme.actions';
import { Theme } from '../../../config/theme.model';
import { provideMockStore } from '@ngrx/store/testing';
import { ROUTER_NAVIGATED } from '@ngrx/router-store';
import { ResolverActionTypes } from '../../core/resolving/resolver.actions';
import { Community } from '../../core/shared/community.model';
import { COMMUNITY } from '../../core/shared/community.resource-type';
import { NoOpAction } from '../ngrx/no-op.action';
import { ITEM } from '../../core/shared/item.resource-type';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Item } from '../../core/shared/item.model';
import { Collection } from '../../core/shared/collection.model';
import { COLLECTION } from '../../core/shared/collection.resource-type';
import {
createNoContentRemoteDataObject$,
createSuccessfulRemoteDataObject$
} from '../remote-data.utils';
import { BASE_THEME_NAME } from './theme.constants';
/**
* LinkService able to mock recursively resolving DSO parent links
* Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until
* none are left, after which it returns a no-content remote-date
*/
class MockLinkService {
index = -1;
constructor(private ancestorDSOs: DSpaceObject[]) {
}
resolveLinkWithoutAttaching() {
if (this.index >= this.ancestorDSOs.length - 1) {
return createNoContentRemoteDataObject$();
} else {
this.index++;
return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]);
}
}
}
describe('ThemeEffects', () => {
let themeEffects: ThemeEffects;
let linkService: LinkService;
let initialState;
let ancestorDSOs: DSpaceObject[];
function init() {
ancestorDSOs = [
Object.assign(new Collection(), {
type: COLLECTION.value,
uuid: 'collection-uuid',
_links: { owningCommunity: { href: 'owning-community-link' } }
}),
Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'sub-community-uuid',
_links: { parentCommunity: { href: 'parent-community-link' } }
}),
Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'top-community-uuid',
}),
];
linkService = new MockLinkService(ancestorDSOs) as any;
initialState = {
theme: {
currentTheme: 'custom',
@@ -82,7 +24,6 @@ describe('ThemeEffects', () => {
TestBed.configureTestingModule({
providers: [
ThemeEffects,
{ provide: LinkService, useValue: linkService },
provideMockStore({ initialState }),
provideMockActions(() => mockActions)
]
@@ -110,205 +51,4 @@ describe('ThemeEffects', () => {
expect(themeEffects.initTheme$).toBeObservable(expected);
});
});
describe('updateThemeOnRouteChange$', () => {
const url = '/test/route';
const dso = Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: '0958c910-2037-42a9-81c7-dca80e3892b4',
});
function spyOnPrivateMethods() {
spyOn((themeEffects as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso]));
spyOn((themeEffects as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' }));
spyOn((themeEffects as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom'));
}
describe('when a resolved action is present', () => {
beforeEach(() => {
setupEffectsWithActions(
hot('--ab-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
b: {
type: ResolverActionTypes.RESOLVED,
payload: { url, dso },
}
})
);
spyOnPrivateMethods();
});
it('should set the theme it receives from the DSO', () => {
const expected = cold('--b-', {
b: new SetThemeAction('custom')
});
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
});
});
describe('when no resolved action is present', () => {
beforeEach(() => {
setupEffectsWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
spyOnPrivateMethods();
});
it('should set the theme it receives from the route url', () => {
const expected = cold('--b-', {
b: new SetThemeAction('custom')
});
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
});
});
describe('when no themes are present', () => {
beforeEach(() => {
setupEffectsWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
(themeEffects as any).themes = [];
});
it('should return an empty action', () => {
const expected = cold('--b-', {
b: new NoOpAction()
});
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
});
});
});
describe('private functions', () => {
beforeEach(() => {
setupEffectsWithActions(hot('-', {}));
});
describe('getActionForMatch', () => {
it('should return a SET action if the new theme differs from the current theme', () => {
const theme = new Theme({ name: 'new-theme' });
expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme'));
});
it('should return an empty action if the new theme equals the current theme', () => {
const theme = new Theme({ name: 'old-theme' });
expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction());
});
});
describe('matchThemeToDSOs', () => {
let themes: Theme[];
let nonMatchingTheme: Theme;
let itemMatchingTheme: Theme;
let communityMatchingTheme: Theme;
let dsos: DSpaceObject[];
beforeEach(() => {
nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), {
matches: () => false
});
itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), {
matches: (url, dso) => (dso as any).type === ITEM.value
});
communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), {
matches: (url, dso) => (dso as any).type === COMMUNITY.value
});
dsos = [
Object.assign(new Item(), {
type: ITEM.value,
uuid: 'item-uuid',
}),
Object.assign(new Collection(), {
type: COLLECTION.value,
uuid: 'collection-uuid',
}),
Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'community-uuid',
}),
];
});
describe('when no themes match any of the DSOs', () => {
beforeEach(() => {
themes = [ nonMatchingTheme ];
themeEffects.themes = themes;
});
it('should return undefined', () => {
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined();
});
});
describe('when one of the themes match a DSOs', () => {
beforeEach(() => {
themes = [ nonMatchingTheme, itemMatchingTheme ];
themeEffects.themes = themes;
});
it('should return the matching theme', () => {
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
});
});
describe('when multiple themes match some of the DSOs', () => {
it('should return the first matching theme', () => {
themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ];
themeEffects.themes = themes;
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ];
themeEffects.themes = themes;
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme);
});
});
});
describe('getAncestorDSOs', () => {
it('should return an array of the provided DSO and its ancestors', (done) => {
const dso = Object.assign(new Item(), {
type: ITEM.value,
uuid: 'item-uuid',
_links: { owningCollection: { href: 'owning-collection-link' } },
});
observableOf(dso).pipe(
(themeEffects as any).getAncestorDSOs()
).subscribe((result) => {
expect(result).toEqual([dso, ...ancestorDSOs]);
done();
});
});
it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => {
const dso = {
type: ITEM.value,
uuid: 'item-uuid',
};
observableOf(dso).pipe(
(themeEffects as any).getAncestorDSOs()
).subscribe((result) => {
expect(result).toEqual([dso]);
done();
});
});
});
});
});

View File

@@ -1,22 +1,9 @@
import { Injectable } from '@angular/core';
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store';
import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { SetThemeAction } from './theme.actions';
import { environment } from '../../../environments/environment';
import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model';
import { hasValue, isNotEmpty, hasNoValue } from '../empty.util';
import { NoOpAction } from '../ngrx/no-op.action';
import { Store, select } from '@ngrx/store';
import { ThemeState } from './theme.reducer';
import { currentThemeSelector } from './theme.service';
import { of as observableOf, EMPTY, Observable } from 'rxjs';
import { ResolverActionTypes, ResolvedAction } from '../../core/resolving/resolver.actions';
import { followLink } from '../utils/follow-link-config.model';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { LinkService } from '../../core/cache/builders/link.service';
import { hasValue, hasNoValue } from '../empty.util';
import { BASE_THEME_NAME } from './theme.constants';
export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =>
@@ -27,16 +14,6 @@ export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =
@Injectable()
export class ThemeEffects {
/**
* The list of configured themes
*/
themes: Theme[];
/**
* True if at least one theme depends on the route
*/
hasDynamicTheme: boolean;
/**
* Initialize with a theme that doesn't depend on the route.
*/
@@ -53,133 +30,8 @@ export class ThemeEffects {
)
);
/**
* An effect that fires when a route change completes,
* and determines whether or not the theme should change
*/
updateThemeOnRouteChange$ = createEffect(() => this.actions$.pipe(
// Listen for when a route change ends
ofType(ROUTER_NAVIGATED),
withLatestFrom(
// Pull in the latest resolved action, or undefined if none was dispatched yet
this.actions$.pipe(ofType(ResolverActionTypes.RESOLVED), startWith(undefined)),
// and the current theme from the store
this.store.pipe(select(currentThemeSelector))
),
switchMap(([navigatedAction, resolvedAction, currentTheme]: [RouterNavigatedAction, ResolvedAction, string]) => {
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
const currentRouteUrl = navigatedAction.payload.routerState.url;
// If resolvedAction exists, and deals with the current url
if (hasValue(resolvedAction) && resolvedAction.payload.url === currentRouteUrl) {
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
return observableOf(resolvedAction.payload.dso).pipe(
this.getAncestorDSOs(),
map((dsos: DSpaceObject[]) => {
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
return this.getActionForMatch(dsoMatch, currentTheme);
})
);
}
// check whether the route itself matches
const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined));
return [this.getActionForMatch(routeMatch, currentTheme)];
}
// If there are no themes configured, do nothing
return [new NoOpAction()];
})
)
);
/**
* return the action to dispatch based on the given matching theme
*
* @param newTheme The theme to create an action for
* @param currentThemeName The name of the currently active theme
* @private
*/
private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction {
if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) {
// If we have a match, and it isn't already the active theme, set it as the new theme
return new SetThemeAction(newTheme.config.name);
} else {
// Otherwise, do nothing
return new NoOpAction();
}
}
/**
* Check the given DSpaceObjects in order to see if they match the configured themes in order.
* If a match is found, the matching theme is returned
*
* @param dsos The DSpaceObjects to check
* @param currentRouteUrl The url for the current route
* @private
*/
private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme {
// iterate over the themes in order, and return the first one that matches
return this.themes.find((theme: Theme) => {
// iterate over the dsos's in order (most specific one first, so Item, Collection,
// Community), and return the first one that matches the current theme
const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso));
return hasValue(match);
});
}
/**
* An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as
* input. The initial DSpaceObject will be the first element of the output array, followed by
* its parent, its grandparent etc
*
* @private
*/
private getAncestorDSOs() {
return (source: Observable<DSpaceObject>): Observable<DSpaceObject[]> =>
source.pipe(
expand((dso: DSpaceObject) => {
// Check if the dso exists and has a parent link
if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') {
const linkName = (dso as any).getParentLinkKey();
// If it does, retrieve it.
return this.linkService.resolveLinkWithoutAttaching<DSpaceObject, DSpaceObject>(dso, followLink(linkName)).pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd.payload)) {
// If there's a parent, use it for the next iteration
return rd.payload;
} else {
// If there's no parent, or an error, return null, which will stop recursion
// in the next iteration
return null;
}
}),
);
}
// The current dso has no value, or no parent. Return EMPTY to stop recursion
return EMPTY;
}),
// only allow through DSOs that have a value
filter((dso: DSpaceObject) => hasValue(dso)),
// Wait for recursion to complete, and emit all results at once, in an array
toArray()
);
}
constructor(
private actions$: Actions,
private store: Store<ThemeState>,
private linkService: LinkService,
) {
// Create objects from the theme configs in the environment file
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig));
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
hasValue(themeConfig.regex) ||
hasValue(themeConfig.handle) ||
hasValue(themeConfig.uuid)
);
}
}

View 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();
});
});
});
});
});

View File

@@ -1,10 +1,26 @@
import { Injectable } from '@angular/core';
import { Injectable, Inject } from '@angular/core';
import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
import { Observable } from 'rxjs/internal/Observable';
import { ThemeState } from './theme.reducer';
import { SetThemeAction } from './theme.actions';
import { take } from 'rxjs/operators';
import { hasValue } from '../empty.util';
import { SetThemeAction, ThemeActionTypes } from './theme.actions';
import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../empty.util';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteData,
getRemoteDataPayload
} from '../../core/shared/operators';
import { EMPTY, of as observableOf } from 'rxjs';
import { Theme, ThemeConfig, themeFactory } from '../../../config/theme.model';
import { NO_OP_ACTION_TYPE, NoOpAction } from '../ngrx/no-op.action';
import { followLink } from '../utils/follow-link-config.model';
import { LinkService } from '../../core/cache/builders/link.service';
import { environment } from '../../../environments/environment';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { ActivatedRouteSnapshot } from '@angular/router';
import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator';
export const themeStateSelector = createFeatureSelector<ThemeState>('theme');
@@ -17,9 +33,29 @@ export const currentThemeSelector = createSelector(
providedIn: 'root'
})
export class ThemeService {
/**
* The list of configured themes
*/
themes: Theme[];
/**
* True if at least one theme depends on the route
*/
hasDynamicTheme: boolean;
constructor(
private store: Store<ThemeState>,
private linkService: LinkService,
private dSpaceObjectDataService: DSpaceObjectDataService,
@Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig
) {
// Create objects from the theme configs in the environment file
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig));
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
hasValue(themeConfig.regex) ||
hasValue(themeConfig.handle) ||
hasValue(themeConfig.uuid)
);
}
setTheme(newName: string) {
@@ -43,4 +79,174 @@ export class ThemeService {
);
}
/**
* Determine whether or not the theme needs to change depending on the current route's URL and snapshot data
* If the snapshot contains a dso, this will be used to match a theme
* If the snapshot contains a scope parameters, this will be used to match a theme
* Otherwise the URL is matched against
* If none of the above find a match, the theme doesn't change
* @param currentRouteUrl
* @param activatedRouteSnapshot
* @return Observable boolean emitting whether or not the theme has been changed
*/
updateThemeOnRouteChange$(currentRouteUrl: string, activatedRouteSnapshot: ActivatedRouteSnapshot): Observable<boolean> {
// and the current theme from the store
const currentTheme$: Observable<string> = this.store.pipe(select(currentThemeSelector));
const action$ = currentTheme$.pipe(
switchMap((currentTheme: string) => {
const snapshotWithData = this.findRouteData(activatedRouteSnapshot);
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
if (hasValue(snapshotWithData) && hasValue(snapshotWithData.data) && hasValue(snapshotWithData.data.dso)) {
const dsoRD: RemoteData<DSpaceObject> = snapshotWithData.data.dso;
if (dsoRD.hasSucceeded) {
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
return observableOf(dsoRD.payload).pipe(
this.getAncestorDSOs(),
map((dsos: DSpaceObject[]) => {
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
return this.getActionForMatch(dsoMatch, currentTheme);
})
);
}
}
if (hasValue(activatedRouteSnapshot.queryParams) && hasValue(activatedRouteSnapshot.queryParams.scope)) {
const dsoFromScope$: Observable<RemoteData<DSpaceObject>> = this.dSpaceObjectDataService.findById(activatedRouteSnapshot.queryParams.scope);
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
return dsoFromScope$.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
this.getAncestorDSOs(),
map((dsos: DSpaceObject[]) => {
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
return this.getActionForMatch(dsoMatch, currentTheme);
})
);
}
// check whether the route itself matches
const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined));
return [this.getActionForMatch(routeMatch, currentTheme)];
}
// If there are no themes configured, do nothing
return [new NoOpAction()];
}),
take(1),
);
action$.pipe(
filter((action) => action.type !== NO_OP_ACTION_TYPE),
).subscribe((action) => {
this.store.dispatch(action);
});
return action$.pipe(
map((action) => action.type === ThemeActionTypes.SET),
);
}
/**
* Find a DSpaceObject in one of the provided route snapshots their data
* Recursively looks for the dso in the routes their child routes until it reaches a dead end or finds one
* @param routes
*/
findRouteData(...routes: ActivatedRouteSnapshot[]) {
const result = routes.find((route) => hasValue(route.data) && hasValue(route.data.dso));
if (hasValue(result)) {
return result;
} else {
const nextLevelRoutes = routes
.map((route: ActivatedRouteSnapshot) => route.children)
.reduce((combined: ActivatedRouteSnapshot[], current: ActivatedRouteSnapshot[]) => [...combined, ...current]);
if (isNotEmpty(nextLevelRoutes)) {
return this.findRouteData(...nextLevelRoutes);
} else {
return undefined;
}
}
}
/**
* An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as
* input. The initial DSpaceObject will be the first element of the output array, followed by
* its parent, its grandparent etc
*
* @private
*/
private getAncestorDSOs() {
return (source: Observable<DSpaceObject>): Observable<DSpaceObject[]> =>
source.pipe(
expand((dso: DSpaceObject) => {
// Check if the dso exists and has a parent link
if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') {
const linkName = (dso as any).getParentLinkKey();
// If it does, retrieve it.
return this.linkService.resolveLinkWithoutAttaching<DSpaceObject, DSpaceObject>(dso, followLink(linkName)).pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd.payload)) {
// If there's a parent, use it for the next iteration
return rd.payload;
} else {
// If there's no parent, or an error, return null, which will stop recursion
// in the next iteration
return null;
}
}),
);
}
// The current dso has no value, or no parent. Return EMPTY to stop recursion
return EMPTY;
}),
// only allow through DSOs that have a value
filter((dso: DSpaceObject) => hasValue(dso)),
// Wait for recursion to complete, and emit all results at once, in an array
toArray()
);
}
/**
* return the action to dispatch based on the given matching theme
*
* @param newTheme The theme to create an action for
* @param currentThemeName The name of the currently active theme
* @private
*/
private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction {
if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) {
// If we have a match, and it isn't already the active theme, set it as the new theme
return new SetThemeAction(newTheme.config.name);
} else {
// Otherwise, do nothing
return new NoOpAction();
}
}
/**
* Check the given DSpaceObjects in order to see if they match the configured themes in order.
* If a match is found, the matching theme is returned
*
* @param dsos The DSpaceObjects to check
* @param currentRouteUrl The url for the current route
* @private
*/
private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme {
// iterate over the themes in order, and return the first one that matches
return this.themes.find((theme: Theme) => {
// iterate over the dsos's in order (most specific one first, so Item, Collection,
// Community), and return the first one that matches the current theme
const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso));
return hasValue(match);
});
}
/**
* Searches for a ThemeConfig by its name;
*/
getThemeConfigFor(themeName: string): ThemeConfig {
return this.gtcf(themeName);
}
}

View File

@@ -5,6 +5,7 @@ import { VarDirective } from '../utils/var.directive';
import { ThemeService } from './theme.service';
import { getMockThemeService } from '../mocks/theme-service.mock';
import { TestComponent } from './test/test.component.spec';
import { ThemeConfig } from '../../../config/theme.model';
/* tslint:disable:max-classes-per-file */
@Component({
@@ -32,8 +33,8 @@ describe('ThemedComponent', () => {
let fixture: ComponentFixture<TestThemedComponent>;
let themeService: ThemeService;
function setupTestingModuleForTheme(theme: string) {
themeService = getMockThemeService(theme);
function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) {
themeService = getMockThemeService(theme, themes);
TestBed.configureTestingModule({
imports: [],
declarations: [TestThemedComponent, VarDirective],
@@ -44,17 +45,20 @@ describe('ThemedComponent', () => {
}).compileComponents();
}
function initComponent() {
fixture = TestBed.createComponent(TestThemedComponent);
component = fixture.componentInstance;
spyOn(component as any, 'importThemedComponent').and.callThrough();
component.testInput = 'changed';
fixture.detectChanges();
}
describe('when the current theme matches a themed component', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('custom');
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestThemedComponent);
component = fixture.componentInstance;
component.testInput = 'changed';
fixture.detectChanges();
});
beforeEach(initComponent);
it('should set compRef to the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
@@ -70,16 +74,12 @@ describe('ThemedComponent', () => {
});
describe('when the current theme doesn\'t match a themed component', () => {
describe('and it doesn\'t extend another theme', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('non-existing-theme');
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestThemedComponent);
component = fixture.componentInstance;
component.testInput = 'changed';
fixture.detectChanges();
});
beforeEach(initComponent);
it('should set compRef to the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
@@ -93,5 +93,108 @@ describe('ThemedComponent', () => {
});
}));
});
describe('and it extends another theme', () => {
describe('that doesn\'t match it either', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('current-theme', [
{ name: 'current-theme', extends: 'non-existing-theme' },
]);
}));
beforeEach(initComponent);
it('should set compRef to the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme');
expect((component as any).compRef.instance.type).toEqual('default');
});
}));
it('should sync up this component\'s input with the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).compRef.instance.testInput).toEqual('changed');
});
}));
});
describe('that does match it', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('current-theme', [
{ name: 'current-theme', extends: 'custom' },
]);
}));
beforeEach(initComponent);
it('should set compRef to the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom');
expect((component as any).compRef.instance.type).toEqual('themed');
});
}));
it('should sync up this component\'s input with the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).compRef.instance.testInput).toEqual('changed');
});
}));
});
describe('that extends another theme that doesn\'t match it either', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('current-theme', [
{ name: 'current-theme', extends: 'parent-theme' },
{ name: 'parent-theme', extends: 'non-existing-theme' },
]);
}));
beforeEach(initComponent);
it('should set compRef to the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme');
expect((component as any).compRef.instance.type).toEqual('default');
});
}));
it('should sync up this component\'s input with the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).compRef.instance.testInput).toEqual('changed');
});
}));
});
describe('that extends another theme that does match it', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('current-theme', [
{ name: 'current-theme', extends: 'parent-theme' },
{ name: 'parent-theme', extends: 'custom' },
]);
}));
beforeEach(initComponent);
it('should set compRef to the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom');
expect((component as any).compRef.instance.type).toEqual('themed');
});
}));
it('should sync up this component\'s input with the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).compRef.instance.testInput).toEqual('changed');
});
}));
});
});
});
});
/* tslint:enable:max-classes-per-file */

View File

@@ -11,7 +11,7 @@ import {
OnChanges
} from '@angular/core';
import { hasValue, isNotEmpty } from '../empty.util';
import { Subscription } from 'rxjs';
import { Observable, of as observableOf, Subscription } from 'rxjs';
import { ThemeService } from './theme.service';
import { fromPromise } from 'rxjs/internal-compatibility';
import { catchError, switchMap, map } from 'rxjs/operators';
@@ -69,11 +69,7 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
this.lazyLoadSub.unsubscribe();
}
this.lazyLoadSub =
fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe(
// if there is no themed version of the component an exception is thrown,
// catch it and return null instead
catchError(() => [null]),
this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe(
switchMap((themedFile: any) => {
if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
// if the file is not null, and exports a component with the specified name,
@@ -113,4 +109,32 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
});
}
}
/**
* Attempt to import this component from the current theme or a theme it {@link NamedThemeConfig.extends}.
* Recurse until we succeed or when until we run out of themes to fall back to.
*
* @param themeName The name of the theme to check
* @param checkedThemeNames The list of theme names that are already checked
* @private
*/
private resolveThemedComponent(themeName?: string, checkedThemeNames: string[] = []): Observable<any> {
if (isNotEmpty(themeName)) {
return fromPromise(this.importThemedComponent(themeName)).pipe(
catchError(() => {
// Try the next ancestor theme instead
const nextTheme = this.themeService.getThemeConfigFor(themeName)?.extends;
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
if (checkedThemeNames.includes(nextTheme)) {
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
} else {
return this.resolveThemedComponent(nextTheme, nextCheckedThemeNames);
}
}),
);
} else {
// If we got here, we've failed to import this component from any ancestor theme → fall back to unthemed
return observableOf(null);
}
}
}

View File

@@ -895,6 +895,30 @@
"collection.select.table.title": "Title",
"collection.source.controls.head": "Harvest Controls",
"collection.source.controls.test.submit.error": "Something went wrong with initiating the testing of the settings",
"collection.source.controls.test.failed": "The script to test the settings has failed",
"collection.source.controls.test.completed": "The script to test the settings has successfully finished",
"collection.source.controls.test.submit": "Test configuration",
"collection.source.controls.test.running": "Testing configuration...",
"collection.source.controls.import.submit.success": "The import has been successfully initiated",
"collection.source.controls.import.submit.error": "Something went wrong with initiating the import",
"collection.source.controls.import.submit": "Import now",
"collection.source.controls.import.running": "Importing...",
"collection.source.controls.import.failed": "An error occurred during the import",
"collection.source.controls.import.completed": "The import completed",
"collection.source.controls.reset.submit.success": "The reset and reimport has been successfully initiated",
"collection.source.controls.reset.submit.error": "Something went wrong with initiating the reset and reimport",
"collection.source.controls.reset.failed": "An error occurred during the reset and reimport",
"collection.source.controls.reset.completed": "The reset and reimport completed",
"collection.source.controls.reset.submit": "Reset and reimport",
"collection.source.controls.reset.running": "Resetting and reimporting...",
"collection.source.controls.harvest.status": "Harvest status:",
"collection.source.controls.harvest.start": "Harvest start time:",
"collection.source.controls.harvest.last": "Last time harvested:",
"collection.source.controls.harvest.message": "Harvest info:",
"collection.source.controls.harvest.no-information": "N/A",
"collection.source.update.notifications.error.content": "The provided settings have been tested and didn't work.",
@@ -1874,6 +1898,10 @@
"item.page.collections": "Collections",
"item.page.collections.loading": "Loading...",
"item.page.collections.load-more": "Load more",
"item.page.date": "Date",
"item.page.edit": "Edit this item",

View File

@@ -6,6 +6,12 @@ import { getDSORoute } from '../app/app-routing-paths';
// tslint:disable:max-classes-per-file
export interface NamedThemeConfig extends Config {
name: string;
/**
* Specify another theme to build upon: whenever a themed component is not found in the current theme,
* its ancestor theme(s) will be checked recursively before falling back to the default theme.
*/
extends?: string;
}
export interface RegExThemeConfig extends NamedThemeConfig {

View File

@@ -265,6 +265,19 @@ export const environment: GlobalConfig = {
// uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
// },
// {
// // The extends property specifies an ancestor theme (by name). Whenever a themed component is not found
// // in the current theme, its ancestor theme(s) will be checked recursively before falling back to default.
// name: 'custom-A',
// extends: 'custom-B',
// // Any of the matching properties above can be used
// handle: '10673/34',
// },
// {
// name: 'custom-B',
// extends: 'custom',
// handle: '10673/12',
// },
// {
// // A theme with only a name will match every route
// name: 'custom'
// },

View File

@@ -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 {
}

View File

@@ -79,8 +79,10 @@ import { HeaderComponent } from './app/header/header.component';
import { FooterComponent } from './app/footer/footer.component';
import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component';
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component';
import { FileSectionComponent} from './app/item-page/simple/field-components/file-section/file-section.component';
const DECLARATIONS = [
FileSectionComponent,
HomePageComponent,
HomeNewsComponent,
RootComponent,