mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge remote-tracking branch 'upstream/main' into w2p-94390_replace-dso-page-edit-buttons-with-a-menu
This commit is contained in:
@@ -104,6 +104,8 @@
|
|||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"klaro": "^0.7.10",
|
"klaro": "^0.7.10",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"markdown-it": "^13.0.1",
|
||||||
|
"markdown-it-mathjax3": "^4.3.1",
|
||||||
"mirador": "^3.3.0",
|
"mirador": "^3.3.0",
|
||||||
"mirador-dl-plugin": "^0.13.0",
|
"mirador-dl-plugin": "^0.13.0",
|
||||||
"mirador-share-plugin": "^0.11.0",
|
"mirador-share-plugin": "^0.11.0",
|
||||||
@@ -116,6 +118,7 @@
|
|||||||
"ngx-moment": "^5.0.0",
|
"ngx-moment": "^5.0.0",
|
||||||
"ngx-pagination": "5.0.0",
|
"ngx-pagination": "5.0.0",
|
||||||
"ngx-sortablejs": "^11.1.0",
|
"ngx-sortablejs": "^11.1.0",
|
||||||
|
"ngx-ui-switch": "^11.0.1",
|
||||||
"nouislider": "^14.6.3",
|
"nouislider": "^14.6.3",
|
||||||
"pem": "1.14.4",
|
"pem": "1.14.4",
|
||||||
"postcss-cli": "^9.1.0",
|
"postcss-cli": "^9.1.0",
|
||||||
@@ -123,13 +126,13 @@
|
|||||||
"react-copy-to-clipboard": "^5.0.1",
|
"react-copy-to-clipboard": "^5.0.1",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.5.5",
|
"rxjs": "^7.5.5",
|
||||||
|
"sanitize-html": "^2.7.2",
|
||||||
"sortablejs": "1.13.0",
|
"sortablejs": "1.13.0",
|
||||||
"tslib": "^2.0.0",
|
"tslib": "^2.0.0",
|
||||||
"url-parse": "^1.5.6",
|
"url-parse": "^1.5.6",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"webfontloader": "1.6.28",
|
"webfontloader": "1.6.28",
|
||||||
"zone.js": "~0.11.5",
|
"zone.js": "~0.11.5"
|
||||||
"ngx-ui-switch": "^11.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "~13.1.0",
|
"@angular-builders/custom-webpack": "~13.1.0",
|
||||||
@@ -155,6 +158,7 @@
|
|||||||
"@types/js-cookie": "2.2.6",
|
"@types/js-cookie": "2.2.6",
|
||||||
"@types/lodash": "^4.14.165",
|
"@types/lodash": "^4.14.165",
|
||||||
"@types/node": "^14.14.9",
|
"@types/node": "^14.14.9",
|
||||||
|
"@types/sanitize-html": "^2.6.2",
|
||||||
"@typescript-eslint/eslint-plugin": "5.11.0",
|
"@typescript-eslint/eslint-plugin": "5.11.0",
|
||||||
"@typescript-eslint/parser": "5.11.0",
|
"@typescript-eslint/parser": "5.11.0",
|
||||||
"axe-core": "^4.3.3",
|
"axe-core": "^4.3.3",
|
||||||
|
@@ -76,6 +76,10 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
const server = express();
|
const server = express();
|
||||||
|
|
||||||
|
// Tell Express to trust X-FORWARDED-* headers from proxies
|
||||||
|
// See https://expressjs.com/en/guide/behind-proxies.html
|
||||||
|
server.set('trust proxy', environment.ui.useProxies);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If production mode is enabled in the environment file:
|
* If production mode is enabled in the environment file:
|
||||||
* - Enable Angular's production mode
|
* - Enable Angular's production mode
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
import { Store, StoreModule } from '@ngrx/store';
|
import { Store, StoreModule } from '@ngrx/store';
|
||||||
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
|
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||||
import { CommonModule, DOCUMENT } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||||
import { Angulartics2GoogleAnalytics } from 'angulartics2';
|
|
||||||
|
|
||||||
// Load the implementations that should be tested
|
// Load the implementations that should be tested
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
@@ -73,7 +72,6 @@ describe('App component', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: NativeWindowService, useValue: new NativeWindowRef() },
|
{ provide: NativeWindowService, useValue: new NativeWindowRef() },
|
||||||
{ provide: MetadataService, useValue: new MetadataServiceMock() },
|
{ provide: MetadataService, useValue: new MetadataServiceMock() },
|
||||||
{ provide: Angulartics2GoogleAnalytics, useValue: new AngularticsProviderMock() },
|
|
||||||
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
|
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
|
||||||
{ provide: AuthService, useValue: new AuthServiceMock() },
|
{ provide: AuthService, useValue: new AuthServiceMock() },
|
||||||
{ provide: Router, useValue: new RouterMock() },
|
{ provide: Router, useValue: new RouterMock() },
|
||||||
|
@@ -11,7 +11,6 @@ import { ActivatedRoute, Params, Router } from '@angular/router';
|
|||||||
import { BrowseService } from '../../core/browse/browse.service';
|
import { BrowseService } from '../../core/browse/browse.service';
|
||||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||||
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
|
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
|
||||||
import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator';
|
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||||
@@ -29,7 +28,6 @@ import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface';
|
|||||||
* A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields.
|
* A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields.
|
||||||
* An example would be 'dateissued' for 'dc.date.issued'
|
* An example would be 'dateissued' for 'dc.date.issued'
|
||||||
*/
|
*/
|
||||||
@rendersBrowseBy(BrowseByDataType.Date)
|
|
||||||
export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
|
export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -0,0 +1,29 @@
|
|||||||
|
import {Component} from '@angular/core';
|
||||||
|
import { ThemedComponent } from '../../shared/theme-support/themed.component';
|
||||||
|
import { BrowseByDatePageComponent } from './browse-by-date-page.component';
|
||||||
|
import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themed wrapper for BrowseByDatePageComponent
|
||||||
|
* */
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-browse-by-metadata-page',
|
||||||
|
styleUrls: [],
|
||||||
|
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||||
|
})
|
||||||
|
|
||||||
|
@rendersBrowseBy(BrowseByDataType.Date)
|
||||||
|
export class ThemedBrowseByDatePageComponent
|
||||||
|
extends ThemedComponent<BrowseByDatePageComponent> {
|
||||||
|
protected getComponentName(): string {
|
||||||
|
return 'BrowseByDatePageComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importThemedComponent(themeName: string): Promise<any> {
|
||||||
|
return import(`../../../themes/${themeName}/app/browse-by/browse-by-date-page/browse-by-date-page.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importUnthemedComponent(): Promise<any> {
|
||||||
|
return import(`./browse-by-date-page.component`);
|
||||||
|
}
|
||||||
|
}
|
@@ -8,10 +8,10 @@
|
|||||||
<ds-comcol-page-header [name]="parentContext.name">
|
<ds-comcol-page-header [name]="parentContext.name">
|
||||||
</ds-comcol-page-header>
|
</ds-comcol-page-header>
|
||||||
<!-- Handle -->
|
<!-- Handle -->
|
||||||
<ds-comcol-page-handle
|
<ds-themed-comcol-page-handle
|
||||||
[content]="parentContext.handle"
|
[content]="parentContext.handle"
|
||||||
[title]="parentContext.type+'.page.handle'" >
|
[title]="parentContext.type+'.page.handle'" >
|
||||||
</ds-comcol-page-handle>
|
</ds-themed-comcol-page-handle>
|
||||||
<!-- Introductory text -->
|
<!-- Introductory text -->
|
||||||
<ds-comcol-page-content [content]="parentContext.introductoryText" [hasInnerHtml]="true">
|
<ds-comcol-page-content [content]="parentContext.introductoryText" [hasInnerHtml]="true">
|
||||||
</ds-comcol-page-content>
|
</ds-comcol-page-content>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
|
import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
|
||||||
import { Component, Inject, OnInit } from '@angular/core';
|
import { Component, Inject, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
import { PaginatedList } from '../../core/data/paginated-list.model';
|
import { PaginatedList } from '../../core/data/paginated-list.model';
|
||||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||||
@@ -14,7 +14,6 @@ import { getFirstSucceededRemoteData } from '../../core/shared/operators';
|
|||||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
|
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
|
||||||
import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator';
|
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
|
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
|
||||||
@@ -32,8 +31,7 @@ export const BBM_PAGINATION_ID = 'bbm';
|
|||||||
* or multiple metadata fields. An example would be 'author' for
|
* or multiple metadata fields. An example would be 'author' for
|
||||||
* 'dc.contributor.*'
|
* 'dc.contributor.*'
|
||||||
*/
|
*/
|
||||||
@rendersBrowseBy(BrowseByDataType.Metadata)
|
export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
|
||||||
export class BrowseByMetadataPageComponent implements OnInit {
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The list of browse-entries to display
|
* The list of browse-entries to display
|
||||||
@@ -93,7 +91,7 @@ export class BrowseByMetadataPageComponent implements OnInit {
|
|||||||
startsWithOptions;
|
startsWithOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value we're browing items for
|
* The value we're browsing items for
|
||||||
* - When the value is not empty, we're browsing items
|
* - When the value is not empty, we're browsing items
|
||||||
* - When the value is empty, we're browsing browse-entries (values for the given metadata definition)
|
* - When the value is empty, we're browsing browse-entries (values for the given metadata definition)
|
||||||
*/
|
*/
|
||||||
|
@@ -0,0 +1,29 @@
|
|||||||
|
import {Component} from '@angular/core';
|
||||||
|
import { ThemedComponent } from '../../shared/theme-support/themed.component';
|
||||||
|
import { BrowseByMetadataPageComponent } from './browse-by-metadata-page.component';
|
||||||
|
import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themed wrapper for BrowseByMetadataPageComponent
|
||||||
|
**/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-browse-by-metadata-page',
|
||||||
|
styleUrls: [],
|
||||||
|
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||||
|
})
|
||||||
|
|
||||||
|
@rendersBrowseBy(BrowseByDataType.Metadata)
|
||||||
|
export class ThemedBrowseByMetadataPageComponent
|
||||||
|
extends ThemedComponent<BrowseByMetadataPageComponent> {
|
||||||
|
protected getComponentName(): string {
|
||||||
|
return 'BrowseByMetadataPageComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importThemedComponent(themeName: string): Promise<any> {
|
||||||
|
return import(`../../../themes/${themeName}/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importUnthemedComponent(): Promise<any> {
|
||||||
|
return import(`./browse-by-metadata-page.component`);
|
||||||
|
}
|
||||||
|
}
|
@@ -9,7 +9,6 @@ import {
|
|||||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||||
import { BrowseService } from '../../core/browse/browse.service';
|
import { BrowseService } from '../../core/browse/browse.service';
|
||||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||||
import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator';
|
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||||
@@ -23,7 +22,6 @@ import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface';
|
|||||||
/**
|
/**
|
||||||
* Component for browsing items by title (dc.title)
|
* Component for browsing items by title (dc.title)
|
||||||
*/
|
*/
|
||||||
@rendersBrowseBy(BrowseByDataType.Title)
|
|
||||||
export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent {
|
export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent {
|
||||||
|
|
||||||
public constructor(protected route: ActivatedRoute,
|
public constructor(protected route: ActivatedRoute,
|
||||||
|
@@ -0,0 +1,29 @@
|
|||||||
|
import {Component} from '@angular/core';
|
||||||
|
import { ThemedComponent } from '../../shared/theme-support/themed.component';
|
||||||
|
import { BrowseByTitlePageComponent } from './browse-by-title-page.component';
|
||||||
|
import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themed wrapper for BrowseByTitlePageComponent
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-browse-by-title-page',
|
||||||
|
styleUrls: [],
|
||||||
|
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||||
|
})
|
||||||
|
|
||||||
|
@rendersBrowseBy(BrowseByDataType.Title)
|
||||||
|
export class ThemedBrowseByTitlePageComponent
|
||||||
|
extends ThemedComponent<BrowseByTitlePageComponent> {
|
||||||
|
protected getComponentName(): string {
|
||||||
|
return 'BrowseByTitlePageComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importThemedComponent(themeName: string): Promise<any> {
|
||||||
|
return import(`../../../themes/${themeName}/app/browse-by/browse-by-title-page/browse-by-title-page.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importUnthemedComponent(): Promise<any> {
|
||||||
|
return import(`./browse-by-title-page.component`);
|
||||||
|
}
|
||||||
|
}
|
@@ -7,12 +7,20 @@ import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-
|
|||||||
import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component';
|
import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component';
|
||||||
import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component';
|
import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component';
|
||||||
import { ComcolModule } from '../shared/comcol/comcol.module';
|
import { ComcolModule } from '../shared/comcol/comcol.module';
|
||||||
|
import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component';
|
||||||
|
import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component';
|
||||||
|
import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component';
|
||||||
|
|
||||||
const ENTRY_COMPONENTS = [
|
const ENTRY_COMPONENTS = [
|
||||||
// put only entry components that use custom decorator
|
// put only entry components that use custom decorator
|
||||||
BrowseByTitlePageComponent,
|
BrowseByTitlePageComponent,
|
||||||
BrowseByMetadataPageComponent,
|
BrowseByMetadataPageComponent,
|
||||||
BrowseByDatePageComponent
|
BrowseByDatePageComponent,
|
||||||
|
|
||||||
|
ThemedBrowseByMetadataPageComponent,
|
||||||
|
ThemedBrowseByDatePageComponent,
|
||||||
|
ThemedBrowseByTitlePageComponent,
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@@ -17,10 +17,10 @@
|
|||||||
</ds-comcol-page-logo>
|
</ds-comcol-page-logo>
|
||||||
|
|
||||||
<!-- Handle -->
|
<!-- Handle -->
|
||||||
<ds-comcol-page-handle
|
<ds-themed-comcol-page-handle
|
||||||
[content]="collection.handle"
|
[content]="collection.handle"
|
||||||
[title]="'collection.page.handle'" >
|
[title]="'collection.page.handle'" >
|
||||||
</ds-comcol-page-handle>
|
</ds-themed-comcol-page-handle>
|
||||||
<!-- Introductory text -->
|
<!-- Introductory text -->
|
||||||
<ds-comcol-page-content
|
<ds-comcol-page-content
|
||||||
[content]="collection.introductoryText"
|
[content]="collection.introductoryText"
|
||||||
|
@@ -10,8 +10,8 @@
|
|||||||
<ds-comcol-page-logo *ngIf="logoRD$" [logo]="(logoRD$ | async)?.payload" [alternateText]="'Community Logo'">
|
<ds-comcol-page-logo *ngIf="logoRD$" [logo]="(logoRD$ | async)?.payload" [alternateText]="'Community Logo'">
|
||||||
</ds-comcol-page-logo>
|
</ds-comcol-page-logo>
|
||||||
<!-- Handle -->
|
<!-- Handle -->
|
||||||
<ds-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'">
|
<ds-themed-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'">
|
||||||
</ds-comcol-page-handle>
|
</ds-themed-comcol-page-handle>
|
||||||
<!-- Introductory text -->
|
<!-- Introductory text -->
|
||||||
<ds-comcol-page-content [content]="communityPayload.introductoryText" [hasInnerHtml]="true">
|
<ds-comcol-page-content [content]="communityPayload.introductoryText" [hasInnerHtml]="true">
|
||||||
</ds-comcol-page-content>
|
</ds-comcol-page-content>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<ds-comcol-role
|
<ds-comcol-role
|
||||||
*ngFor="let comcolRole of getComcolRoles$() | async"
|
*ngFor="let comcolRole of comcolRoles$ | async"
|
||||||
[dso]="community$ | async"
|
[dso]="community$ | async"
|
||||||
[comcolRole]="comcolRole"
|
[comcolRole]="comcolRole"
|
||||||
>
|
>
|
||||||
|
@@ -78,8 +78,9 @@ describe('CommunityRolesComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display a community admin role component', () => {
|
it('should display a community admin role component', (done) => {
|
||||||
expect(de.query(By.css('ds-comcol-role .community-admin')))
|
expect(de.query(By.css('ds-comcol-role .community-admin')))
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -19,28 +19,14 @@ export class CommunityRolesComponent implements OnInit {
|
|||||||
dsoRD$: Observable<RemoteData<Community>>;
|
dsoRD$: Observable<RemoteData<Community>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The community to manage, as an observable.
|
* The different roles for the community, as an observable.
|
||||||
*/
|
*/
|
||||||
get community$(): Observable<Community> {
|
comcolRoles$: Observable<HALLink[]>;
|
||||||
return this.dsoRD$.pipe(
|
|
||||||
getFirstSucceededRemoteData(),
|
|
||||||
getRemoteDataPayload(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The different roles for the community.
|
* The community to manage, as an observable.
|
||||||
*/
|
*/
|
||||||
getComcolRoles$(): Observable<HALLink[]> {
|
community$: Observable<Community>;
|
||||||
return this.community$.pipe(
|
|
||||||
map((community) => [
|
|
||||||
{
|
|
||||||
name: 'community-admin',
|
|
||||||
href: community._links.adminGroup.href,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected route: ActivatedRoute,
|
protected route: ActivatedRoute,
|
||||||
@@ -52,5 +38,22 @@ export class CommunityRolesComponent implements OnInit {
|
|||||||
first(),
|
first(),
|
||||||
map((data) => data.dso),
|
map((data) => data.dso),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.community$ = this.dsoRD$.pipe(
|
||||||
|
getFirstSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The different roles for the community.
|
||||||
|
*/
|
||||||
|
this.comcolRoles$ = this.community$.pipe(
|
||||||
|
map((community) => [
|
||||||
|
{
|
||||||
|
name: 'community-admin',
|
||||||
|
href: community._links.adminGroup.href,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -180,17 +180,20 @@ describe('CommunityPageSubCollectionList Component', () => {
|
|||||||
comp.community = mockCommunity;
|
comp.community = mockCommunity;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display a list of collections', () => {
|
|
||||||
subCollList = collections;
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const collList = fixture.debugElement.queryAll(By.css('li'));
|
it('should display a list of collections', () => {
|
||||||
expect(collList.length).toEqual(5);
|
waitForAsync(() => {
|
||||||
expect(collList[0].nativeElement.textContent).toContain('Collection 1');
|
subCollList = collections;
|
||||||
expect(collList[1].nativeElement.textContent).toContain('Collection 2');
|
fixture.detectChanges();
|
||||||
expect(collList[2].nativeElement.textContent).toContain('Collection 3');
|
|
||||||
expect(collList[3].nativeElement.textContent).toContain('Collection 4');
|
const collList = fixture.debugElement.queryAll(By.css('li'));
|
||||||
expect(collList[4].nativeElement.textContent).toContain('Collection 5');
|
expect(collList.length).toEqual(5);
|
||||||
|
expect(collList[0].nativeElement.textContent).toContain('Collection 1');
|
||||||
|
expect(collList[1].nativeElement.textContent).toContain('Collection 2');
|
||||||
|
expect(collList[2].nativeElement.textContent).toContain('Collection 3');
|
||||||
|
expect(collList[3].nativeElement.textContent).toContain('Collection 4');
|
||||||
|
expect(collList[4].nativeElement.textContent).toContain('Collection 5');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not display the header when list of collections is empty', () => {
|
it('should not display the header when list of collections is empty', () => {
|
||||||
|
@@ -181,17 +181,20 @@ describe('CommunityPageSubCommunityListComponent Component', () => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display a list of sub-communities', () => {
|
|
||||||
subCommList = subcommunities;
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const subComList = fixture.debugElement.queryAll(By.css('li'));
|
it('should display a list of sub-communities', () => {
|
||||||
expect(subComList.length).toEqual(5);
|
waitForAsync(() => {
|
||||||
expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1');
|
subCommList = subcommunities;
|
||||||
expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2');
|
fixture.detectChanges();
|
||||||
expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3');
|
|
||||||
expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4');
|
const subComList = fixture.debugElement.queryAll(By.css('li'));
|
||||||
expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5');
|
expect(subComList.length).toEqual(5);
|
||||||
|
expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1');
|
||||||
|
expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2');
|
||||||
|
expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3');
|
||||||
|
expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4');
|
||||||
|
expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not display the header when list of sub-communities is empty', () => {
|
it('should not display the header when list of sub-communities is empty', () => {
|
||||||
|
54
src/app/core/cache/object-cache.actions.ts
vendored
54
src/app/core/cache/object-cache.actions.ts
vendored
@@ -13,7 +13,9 @@ export const ObjectCacheActionTypes = {
|
|||||||
REMOVE: type('dspace/core/cache/object/REMOVE'),
|
REMOVE: type('dspace/core/cache/object/REMOVE'),
|
||||||
RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'),
|
RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'),
|
||||||
ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'),
|
ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'),
|
||||||
APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH')
|
APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH'),
|
||||||
|
ADD_DEPENDENTS: type('dspace/core/cache/object/ADD_DEPENDENTS'),
|
||||||
|
REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS')
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,13 +128,55 @@ export class ApplyPatchObjectCacheAction implements Action {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An NgRx action to add dependent request UUIDs to a cached object
|
||||||
|
*/
|
||||||
|
export class AddDependentsObjectCacheAction implements Action {
|
||||||
|
type = ObjectCacheActionTypes.ADD_DEPENDENTS;
|
||||||
|
payload: {
|
||||||
|
href: string;
|
||||||
|
dependentRequestUUIDs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new AddDependentsObjectCacheAction
|
||||||
|
*
|
||||||
|
* @param href the self link of a cached object
|
||||||
|
* @param dependentRequestUUIDs the UUID of the request that depends on this object
|
||||||
|
*/
|
||||||
|
constructor(href: string, dependentRequestUUIDs: string[]) {
|
||||||
|
this.payload = {
|
||||||
|
href,
|
||||||
|
dependentRequestUUIDs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An NgRx action to remove all dependent request UUIDs from a cached object
|
||||||
|
*/
|
||||||
|
export class RemoveDependentsObjectCacheAction implements Action {
|
||||||
|
type = ObjectCacheActionTypes.REMOVE_DEPENDENTS;
|
||||||
|
payload: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new RemoveDependentsObjectCacheAction
|
||||||
|
*
|
||||||
|
* @param href the self link of a cached object for which to remove all dependent request UUIDs
|
||||||
|
*/
|
||||||
|
constructor(href: string) {
|
||||||
|
this.payload = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type to encompass all ObjectCacheActions
|
* A type to encompass all ObjectCacheActions
|
||||||
*/
|
*/
|
||||||
export type ObjectCacheAction
|
export type ObjectCacheAction
|
||||||
= AddToObjectCacheAction
|
= AddToObjectCacheAction
|
||||||
| RemoveFromObjectCacheAction
|
| RemoveFromObjectCacheAction
|
||||||
| ResetObjectCacheTimestampsAction
|
| ResetObjectCacheTimestampsAction
|
||||||
| AddPatchObjectCacheAction
|
| AddPatchObjectCacheAction
|
||||||
| ApplyPatchObjectCacheAction;
|
| ApplyPatchObjectCacheAction
|
||||||
|
| AddDependentsObjectCacheAction
|
||||||
|
| RemoveDependentsObjectCacheAction;
|
||||||
|
28
src/app/core/cache/object-cache.reducer.spec.ts
vendored
28
src/app/core/cache/object-cache.reducer.spec.ts
vendored
@@ -2,11 +2,13 @@ import * as deepFreeze from 'deep-freeze';
|
|||||||
import { Operation } from 'fast-json-patch';
|
import { Operation } from 'fast-json-patch';
|
||||||
import { Item } from '../shared/item.model';
|
import { Item } from '../shared/item.model';
|
||||||
import {
|
import {
|
||||||
|
AddDependentsObjectCacheAction,
|
||||||
AddPatchObjectCacheAction,
|
AddPatchObjectCacheAction,
|
||||||
AddToObjectCacheAction,
|
AddToObjectCacheAction,
|
||||||
ApplyPatchObjectCacheAction,
|
ApplyPatchObjectCacheAction,
|
||||||
|
RemoveDependentsObjectCacheAction,
|
||||||
RemoveFromObjectCacheAction,
|
RemoveFromObjectCacheAction,
|
||||||
ResetObjectCacheTimestampsAction
|
ResetObjectCacheTimestampsAction,
|
||||||
} from './object-cache.actions';
|
} from './object-cache.actions';
|
||||||
|
|
||||||
import { objectCacheReducer } from './object-cache.reducer';
|
import { objectCacheReducer } from './object-cache.reducer';
|
||||||
@@ -42,20 +44,22 @@ describe('objectCacheReducer', () => {
|
|||||||
timeCompleted: new Date().getTime(),
|
timeCompleted: new Date().getTime(),
|
||||||
msToLive: 900000,
|
msToLive: 900000,
|
||||||
requestUUIDs: [requestUUID1],
|
requestUUIDs: [requestUUID1],
|
||||||
|
dependentRequestUUIDs: [],
|
||||||
patches: [],
|
patches: [],
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
},
|
},
|
||||||
[selfLink2]: {
|
[selfLink2]: {
|
||||||
data: {
|
data: {
|
||||||
type: Item.type,
|
type: Item.type,
|
||||||
self: requestUUID2,
|
self: selfLink2,
|
||||||
foo: 'baz',
|
foo: 'baz',
|
||||||
_links: { self: { href: requestUUID2 } }
|
_links: { self: { href: selfLink2 } }
|
||||||
},
|
},
|
||||||
alternativeLinks: [altLink3, altLink4],
|
alternativeLinks: [altLink3, altLink4],
|
||||||
timeCompleted: new Date().getTime(),
|
timeCompleted: new Date().getTime(),
|
||||||
msToLive: 900000,
|
msToLive: 900000,
|
||||||
requestUUIDs: [selfLink2],
|
requestUUIDs: [requestUUID2],
|
||||||
|
dependentRequestUUIDs: [requestUUID1],
|
||||||
patches: [],
|
patches: [],
|
||||||
isDirty: false
|
isDirty: false
|
||||||
}
|
}
|
||||||
@@ -189,4 +193,20 @@ describe('objectCacheReducer', () => {
|
|||||||
expect((newState[selfLink1].data as any).name).toEqual(newName);
|
expect((newState[selfLink1].data as any).name).toEqual(newName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add dependent requests on ADD_DEPENDENTS', () => {
|
||||||
|
let newState = objectCacheReducer(testState, new AddDependentsObjectCacheAction(selfLink1, ['new', 'newer', 'newest']));
|
||||||
|
expect(newState[selfLink1].dependentRequestUUIDs).toEqual(['new', 'newer', 'newest']);
|
||||||
|
|
||||||
|
newState = objectCacheReducer(newState, new AddDependentsObjectCacheAction(selfLink2, ['more']));
|
||||||
|
expect(newState[selfLink2].dependentRequestUUIDs).toEqual([requestUUID1, 'more']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear dependent requests on REMOVE_DEPENDENTS', () => {
|
||||||
|
let newState = objectCacheReducer(testState, new RemoveDependentsObjectCacheAction(selfLink1));
|
||||||
|
expect(newState[selfLink1].dependentRequestUUIDs).toEqual([]);
|
||||||
|
|
||||||
|
newState = objectCacheReducer(newState, new RemoveDependentsObjectCacheAction(selfLink2));
|
||||||
|
expect(newState[selfLink2].dependentRequestUUIDs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
66
src/app/core/cache/object-cache.reducer.ts
vendored
66
src/app/core/cache/object-cache.reducer.ts
vendored
@@ -1,12 +1,13 @@
|
|||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
import {
|
import {
|
||||||
|
AddDependentsObjectCacheAction,
|
||||||
AddPatchObjectCacheAction,
|
AddPatchObjectCacheAction,
|
||||||
AddToObjectCacheAction,
|
AddToObjectCacheAction,
|
||||||
ApplyPatchObjectCacheAction,
|
ApplyPatchObjectCacheAction,
|
||||||
ObjectCacheAction,
|
ObjectCacheAction,
|
||||||
ObjectCacheActionTypes,
|
ObjectCacheActionTypes, RemoveDependentsObjectCacheAction,
|
||||||
RemoveFromObjectCacheAction,
|
RemoveFromObjectCacheAction,
|
||||||
ResetObjectCacheTimestampsAction
|
ResetObjectCacheTimestampsAction,
|
||||||
} from './object-cache.actions';
|
} from './object-cache.actions';
|
||||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { CacheEntry } from './cache-entry';
|
import { CacheEntry } from './cache-entry';
|
||||||
@@ -69,6 +70,12 @@ export class ObjectCacheEntry implements CacheEntry {
|
|||||||
*/
|
*/
|
||||||
requestUUIDs: string[];
|
requestUUIDs: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of UUIDs for the requests that depend on this object.
|
||||||
|
* When this object is invalidated, these requests will be invalidated as well.
|
||||||
|
*/
|
||||||
|
dependentRequestUUIDs: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An array of patches that were made on the client side to this entry, but haven't been sent to the server yet
|
* An array of patches that were made on the client side to this entry, but haven't been sent to the server yet
|
||||||
*/
|
*/
|
||||||
@@ -134,6 +141,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi
|
|||||||
return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction);
|
return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case ObjectCacheActionTypes.ADD_DEPENDENTS: {
|
||||||
|
return addDependentsObjectCacheState(state, action as AddDependentsObjectCacheAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
case ObjectCacheActionTypes.REMOVE_DEPENDENTS: {
|
||||||
|
return removeDependentsObjectCacheState(state, action as RemoveDependentsObjectCacheAction);
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -159,6 +174,7 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio
|
|||||||
timeCompleted: action.payload.timeCompleted,
|
timeCompleted: action.payload.timeCompleted,
|
||||||
msToLive: action.payload.msToLive,
|
msToLive: action.payload.msToLive,
|
||||||
requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])],
|
requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])],
|
||||||
|
dependentRequestUUIDs: existing.dependentRequestUUIDs || [],
|
||||||
isDirty: isNotEmpty(existing.patches),
|
isDirty: isNotEmpty(existing.patches),
|
||||||
patches: existing.patches || [],
|
patches: existing.patches || [],
|
||||||
alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks]
|
alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks]
|
||||||
@@ -252,3 +268,49 @@ function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObject
|
|||||||
}
|
}
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a list of dependent request UUIDs to a cached object, used when defining new dependencies
|
||||||
|
*
|
||||||
|
* @param state the current state
|
||||||
|
* @param action an AddDependentsObjectCacheAction
|
||||||
|
* @return the new state, with the dependent requests of the cached object updated
|
||||||
|
*/
|
||||||
|
function addDependentsObjectCacheState(state: ObjectCacheState, action: AddDependentsObjectCacheAction): ObjectCacheState {
|
||||||
|
const href = action.payload.href;
|
||||||
|
const newState = Object.assign({}, state);
|
||||||
|
|
||||||
|
if (hasValue(newState[href])) {
|
||||||
|
newState[href] = Object.assign({}, newState[href], {
|
||||||
|
dependentRequestUUIDs: [
|
||||||
|
...new Set([
|
||||||
|
...newState[href]?.dependentRequestUUIDs || [],
|
||||||
|
...action.payload.dependentRequestUUIDs,
|
||||||
|
])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all dependent request UUIDs from a cached object, used to clear out-of-date depedencies
|
||||||
|
*
|
||||||
|
* @param state the current state
|
||||||
|
* @param action an AddDependentsObjectCacheAction
|
||||||
|
* @return the new state, with the dependent requests of the cached object updated
|
||||||
|
*/
|
||||||
|
function removeDependentsObjectCacheState(state: ObjectCacheState, action: RemoveDependentsObjectCacheAction): ObjectCacheState {
|
||||||
|
const href = action.payload;
|
||||||
|
const newState = Object.assign({}, state);
|
||||||
|
|
||||||
|
if (hasValue(newState[href])) {
|
||||||
|
newState[href] = Object.assign({}, newState[href], {
|
||||||
|
dependentRequestUUIDs: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
133
src/app/core/cache/object-cache.service.spec.ts
vendored
133
src/app/core/cache/object-cache.service.spec.ts
vendored
@@ -11,10 +11,12 @@ import { coreReducers} from '../core.reducers';
|
|||||||
import { RestRequestMethod } from '../data/rest-request-method';
|
import { RestRequestMethod } from '../data/rest-request-method';
|
||||||
import { Item } from '../shared/item.model';
|
import { Item } from '../shared/item.model';
|
||||||
import {
|
import {
|
||||||
|
AddDependentsObjectCacheAction,
|
||||||
|
RemoveDependentsObjectCacheAction,
|
||||||
AddPatchObjectCacheAction,
|
AddPatchObjectCacheAction,
|
||||||
AddToObjectCacheAction,
|
AddToObjectCacheAction,
|
||||||
ApplyPatchObjectCacheAction,
|
ApplyPatchObjectCacheAction,
|
||||||
RemoveFromObjectCacheAction
|
RemoveFromObjectCacheAction,
|
||||||
} from './object-cache.actions';
|
} from './object-cache.actions';
|
||||||
import { Patch } from './object-cache.reducer';
|
import { Patch } from './object-cache.reducer';
|
||||||
import { ObjectCacheService } from './object-cache.service';
|
import { ObjectCacheService } from './object-cache.service';
|
||||||
@@ -25,6 +27,7 @@ import { storeModuleConfig } from '../../app.reducer';
|
|||||||
import { TestColdObservable } from 'jasmine-marbles/src/test-observables';
|
import { TestColdObservable } from 'jasmine-marbles/src/test-observables';
|
||||||
import { IndexName } from '../index/index-name.model';
|
import { IndexName } from '../index/index-name.model';
|
||||||
import { CoreState } from '../core-state.model';
|
import { CoreState } from '../core-state.model';
|
||||||
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
|
|
||||||
describe('ObjectCacheService', () => {
|
describe('ObjectCacheService', () => {
|
||||||
let service: ObjectCacheService;
|
let service: ObjectCacheService;
|
||||||
@@ -38,6 +41,7 @@ describe('ObjectCacheService', () => {
|
|||||||
let altLink1;
|
let altLink1;
|
||||||
let altLink2;
|
let altLink2;
|
||||||
let requestUUID;
|
let requestUUID;
|
||||||
|
let requestUUID2;
|
||||||
let alternativeLink;
|
let alternativeLink;
|
||||||
let timestamp;
|
let timestamp;
|
||||||
let timestamp2;
|
let timestamp2;
|
||||||
@@ -55,6 +59,7 @@ describe('ObjectCacheService', () => {
|
|||||||
altLink1 = 'https://alternative.link/endpoint/1234';
|
altLink1 = 'https://alternative.link/endpoint/1234';
|
||||||
altLink2 = 'https://alternative.link/endpoint/5678';
|
altLink2 = 'https://alternative.link/endpoint/5678';
|
||||||
requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736';
|
requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736';
|
||||||
|
requestUUID2 = 'c0f486c1-c4d3-4a03-b293-ca5b71ff0054';
|
||||||
alternativeLink = 'https://rest.api/endpoint/5e4f8a5-be98-4c51-9fd8-6bfedcbd59b7/item';
|
alternativeLink = 'https://rest.api/endpoint/5e4f8a5-be98-4c51-9fd8-6bfedcbd59b7/item';
|
||||||
timestamp = new Date().getTime();
|
timestamp = new Date().getTime();
|
||||||
timestamp2 = new Date().getTime() - 200;
|
timestamp2 = new Date().getTime() - 200;
|
||||||
@@ -71,13 +76,17 @@ describe('ObjectCacheService', () => {
|
|||||||
data: objectToCache,
|
data: objectToCache,
|
||||||
timeCompleted: timestamp,
|
timeCompleted: timestamp,
|
||||||
msToLive: msToLive,
|
msToLive: msToLive,
|
||||||
alternativeLinks: [altLink1, altLink2]
|
alternativeLinks: [altLink1, altLink2],
|
||||||
|
requestUUIDs: [requestUUID],
|
||||||
|
dependentRequestUUIDs: [],
|
||||||
};
|
};
|
||||||
cacheEntry2 = {
|
cacheEntry2 = {
|
||||||
data: objectToCache,
|
data: objectToCache,
|
||||||
timeCompleted: timestamp2,
|
timeCompleted: timestamp2,
|
||||||
msToLive: msToLive2,
|
msToLive: msToLive2,
|
||||||
alternativeLinks: [altLink2]
|
alternativeLinks: [altLink2],
|
||||||
|
requestUUIDs: [requestUUID2],
|
||||||
|
dependentRequestUUIDs: [],
|
||||||
};
|
};
|
||||||
invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 });
|
invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 });
|
||||||
operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation];
|
operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation];
|
||||||
@@ -343,4 +352,122 @@ describe('ObjectCacheService', () => {
|
|||||||
expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink));
|
expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('request dependencies', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const state = Object.assign({}, initialState, {
|
||||||
|
core: Object.assign({}, initialState.core, {
|
||||||
|
'cache/object': {
|
||||||
|
['objectWithoutDependents']: {
|
||||||
|
dependentRequestUUIDs: [],
|
||||||
|
},
|
||||||
|
['objectWithDependents']: {
|
||||||
|
dependentRequestUUIDs: [requestUUID],
|
||||||
|
},
|
||||||
|
[selfLink]: cacheEntry,
|
||||||
|
},
|
||||||
|
'index': {
|
||||||
|
'object/alt-link-to-self-link': {
|
||||||
|
[anotherLink]: selfLink,
|
||||||
|
['objectWithoutDependentsAlt']: 'objectWithoutDependents',
|
||||||
|
['objectWithDependentsAlt']: 'objectWithDependents',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
mockStore.setState(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addDependency', () => {
|
||||||
|
it('should dispatch an ADD_DEPENDENTS action', () => {
|
||||||
|
service.addDependency(selfLink, 'objectWithoutDependents');
|
||||||
|
expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve alt links', () => {
|
||||||
|
service.addDependency(anotherLink, 'objectWithoutDependentsAlt');
|
||||||
|
expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch if either href cannot be resolved to a cached self link', () => {
|
||||||
|
service.addDependency(selfLink, 'unknown');
|
||||||
|
service.addDependency('unknown', 'objectWithoutDependents');
|
||||||
|
service.addDependency('nothing', 'matches');
|
||||||
|
expect(store.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch if either href is undefined', () => {
|
||||||
|
service.addDependency(selfLink, undefined);
|
||||||
|
service.addDependency(undefined, 'objectWithoutDependents');
|
||||||
|
service.addDependency(undefined, undefined);
|
||||||
|
expect(store.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch if the dependency exists already', () => {
|
||||||
|
service.addDependency(selfLink, 'objectWithDependents');
|
||||||
|
expect(store.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with observable hrefs', () => {
|
||||||
|
service.addDependency(observableOf(selfLink), observableOf('objectWithoutDependents'));
|
||||||
|
expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only dispatch once for the first value of either observable href', () => {
|
||||||
|
const testScheduler = new TestScheduler((actual, expected) => {
|
||||||
|
expect(actual).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
testScheduler.run(({ cold: tsCold, flush }) => {
|
||||||
|
const href$ = tsCold('--y-n-n', {
|
||||||
|
y: selfLink,
|
||||||
|
n: 'NOPE'
|
||||||
|
});
|
||||||
|
const dependsOnHref$ = tsCold('-y-n-n', {
|
||||||
|
y: 'objectWithoutDependents',
|
||||||
|
n: 'NOPE'
|
||||||
|
});
|
||||||
|
|
||||||
|
service.addDependency(href$, dependsOnHref$);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch if either of the hrefs emits undefined', () => {
|
||||||
|
const testScheduler = new TestScheduler((actual, expected) => {
|
||||||
|
expect(actual).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
testScheduler.run(({ cold: tsCold, flush }) => {
|
||||||
|
const undefined$ = tsCold('--u');
|
||||||
|
|
||||||
|
service.addDependency(selfLink, undefined$);
|
||||||
|
service.addDependency(undefined$, 'objectWithoutDependents');
|
||||||
|
service.addDependency(undefined$, undefined$);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
expect(store.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeDependents', () => {
|
||||||
|
it('should dispatch a REMOVE_DEPENDENTS action', () => {
|
||||||
|
service.removeDependents('objectWithDependents');
|
||||||
|
expect(store.dispatch).toHaveBeenCalledOnceWith(new RemoveDependentsObjectCacheAction('objectWithDependents'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve alt links', () => {
|
||||||
|
service.removeDependents('objectWithDependentsAlt');
|
||||||
|
expect(store.dispatch).toHaveBeenCalledOnceWith(new RemoveDependentsObjectCacheAction('objectWithDependents'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch if the href cannot be resolved to a cached self link', () => {
|
||||||
|
service.removeDependents('unknown');
|
||||||
|
expect(store.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
107
src/app/core/cache/object-cache.service.ts
vendored
107
src/app/core/cache/object-cache.service.ts
vendored
@@ -4,23 +4,15 @@ import { applyPatch, Operation } from 'fast-json-patch';
|
|||||||
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
|
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
|
||||||
|
|
||||||
import { distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
|
||||||
import { hasValue, isNotEmpty, isEmpty } from '../../shared/empty.util';
|
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { CoreState } from '../core-state.model';
|
import { CoreState } from '../core-state.model';
|
||||||
import { coreSelector } from '../core.selectors';
|
import { coreSelector } from '../core.selectors';
|
||||||
import { RestRequestMethod } from '../data/rest-request-method';
|
import { RestRequestMethod } from '../data/rest-request-method';
|
||||||
import {
|
import { selfLinkFromAlternativeLinkSelector, selfLinkFromUuidSelector } from '../index/index.selectors';
|
||||||
selfLinkFromAlternativeLinkSelector,
|
|
||||||
selfLinkFromUuidSelector
|
|
||||||
} from '../index/index.selectors';
|
|
||||||
import { GenericConstructor } from '../shared/generic-constructor';
|
import { GenericConstructor } from '../shared/generic-constructor';
|
||||||
import { getClassForType } from './builders/build-decorators';
|
import { getClassForType } from './builders/build-decorators';
|
||||||
import { LinkService } from './builders/link.service';
|
import { LinkService } from './builders/link.service';
|
||||||
import {
|
import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions';
|
||||||
AddPatchObjectCacheAction,
|
|
||||||
AddToObjectCacheAction,
|
|
||||||
ApplyPatchObjectCacheAction,
|
|
||||||
RemoveFromObjectCacheAction
|
|
||||||
} from './object-cache.actions';
|
|
||||||
|
|
||||||
import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer';
|
import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer';
|
||||||
import { AddToSSBAction } from './server-sync-buffer.actions';
|
import { AddToSSBAction } from './server-sync-buffer.actions';
|
||||||
@@ -339,4 +331,97 @@ export class ObjectCacheService {
|
|||||||
this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink));
|
this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new dependency between two cached objects.
|
||||||
|
* When {@link dependsOnHref$} is invalidated, {@link href$} will be invalidated as well.
|
||||||
|
*
|
||||||
|
* This method should be called _after_ requests have been sent;
|
||||||
|
* it will only work if both objects are already present in the cache.
|
||||||
|
*
|
||||||
|
* If either object is undefined, the dependency will not be added
|
||||||
|
*
|
||||||
|
* @param href$ the href of an object to add a dependency to
|
||||||
|
* @param dependsOnHref$ the href of the new dependency
|
||||||
|
*/
|
||||||
|
addDependency(href$: string | Observable<string>, dependsOnHref$: string | Observable<string>) {
|
||||||
|
if (hasNoValue(href$) || hasNoValue(dependsOnHref$)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof href$ === 'string') {
|
||||||
|
href$ = observableOf(href$);
|
||||||
|
}
|
||||||
|
if (typeof dependsOnHref$ === 'string') {
|
||||||
|
dependsOnHref$ = observableOf(dependsOnHref$);
|
||||||
|
}
|
||||||
|
|
||||||
|
observableCombineLatest([
|
||||||
|
href$,
|
||||||
|
dependsOnHref$.pipe(
|
||||||
|
switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref))
|
||||||
|
),
|
||||||
|
]).pipe(
|
||||||
|
switchMap(([href, dependsOnSelfLink]: [string, string]) => {
|
||||||
|
const dependsOnSelfLink$ = observableOf(dependsOnSelfLink);
|
||||||
|
|
||||||
|
return observableCombineLatest([
|
||||||
|
dependsOnSelfLink$,
|
||||||
|
dependsOnSelfLink$.pipe(
|
||||||
|
switchMap(selfLink => this.getBySelfLink(selfLink)),
|
||||||
|
map(oce => oce?.dependentRequestUUIDs || []),
|
||||||
|
),
|
||||||
|
this.getByHref(href).pipe(
|
||||||
|
// only add the latest request to keep dependency index from growing indefinitely
|
||||||
|
map((entry: ObjectCacheEntry) => entry?.requestUUIDs?.[0]),
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
take(1),
|
||||||
|
).subscribe(([dependsOnSelfLink, currentDependents, newDependent]: [string, string[], string]) => {
|
||||||
|
// don't dispatch if either href is invalid or if the new dependency already exists
|
||||||
|
if (hasValue(dependsOnSelfLink) && hasValue(newDependent) && !currentDependents.includes(newDependent)) {
|
||||||
|
this.store.dispatch(new AddDependentsObjectCacheAction(dependsOnSelfLink, [newDependent]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all dependent requests associated with a cache entry.
|
||||||
|
*
|
||||||
|
* @href the href of a cached object
|
||||||
|
*/
|
||||||
|
removeDependents(href: string) {
|
||||||
|
this.resolveSelfLink(href).pipe(
|
||||||
|
take(1),
|
||||||
|
).subscribe((selfLink: string) => {
|
||||||
|
if (hasValue(selfLink)) {
|
||||||
|
this.store.dispatch(new RemoveDependentsObjectCacheAction(selfLink));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the self link of an existing cached object from an arbitrary href
|
||||||
|
*
|
||||||
|
* @param href any href
|
||||||
|
* @return an observable of the self link corresponding to the given href.
|
||||||
|
* Will emit the given href if it was a self link, another href
|
||||||
|
* if the given href was an alt link, or undefined if there is no
|
||||||
|
* cached object for this href.
|
||||||
|
*/
|
||||||
|
private resolveSelfLink(href: string): Observable<string> {
|
||||||
|
return this.getBySelfLink(href).pipe(
|
||||||
|
switchMap((oce: ObjectCacheEntry) => {
|
||||||
|
if (isNotEmpty(oce)) {
|
||||||
|
return [href];
|
||||||
|
} else {
|
||||||
|
return this.store.pipe(
|
||||||
|
select(selfLinkFromAlternativeLinkSelector(href)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -10,7 +10,7 @@ import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.s
|
|||||||
import { HALEndpointService } from '../../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../../shared/hal-endpoint.service';
|
||||||
import { ObjectCacheService } from '../../cache/object-cache.service';
|
import { ObjectCacheService } from '../../cache/object-cache.service';
|
||||||
import { FindListOptions } from '../find-list-options.model';
|
import { FindListOptions } from '../find-list-options.model';
|
||||||
import { Observable, of as observableOf } from 'rxjs';
|
import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs';
|
||||||
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
|
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
|
||||||
import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
|
import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
|
||||||
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
|
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
|
||||||
@@ -20,6 +20,7 @@ import { RemoteData } from '../remote-data';
|
|||||||
import { RequestEntryState } from '../request-entry-state.model';
|
import { RequestEntryState } from '../request-entry-state.model';
|
||||||
import { fakeAsync, tick } from '@angular/core/testing';
|
import { fakeAsync, tick } from '@angular/core/testing';
|
||||||
import { BaseDataService } from './base-data.service';
|
import { BaseDataService } from './base-data.service';
|
||||||
|
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||||
|
|
||||||
const endpoint = 'https://rest.api/core';
|
const endpoint = 'https://rest.api/core';
|
||||||
|
|
||||||
@@ -65,7 +66,13 @@ describe('BaseDataService', () => {
|
|||||||
},
|
},
|
||||||
getByHref: () => {
|
getByHref: () => {
|
||||||
/* empty */
|
/* empty */
|
||||||
}
|
},
|
||||||
|
addDependency: () => {
|
||||||
|
/* empty */
|
||||||
|
},
|
||||||
|
removeDependents: () => {
|
||||||
|
/* empty */
|
||||||
|
},
|
||||||
} as any;
|
} as any;
|
||||||
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
|
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
|
||||||
linksToFollow = [
|
linksToFollow = [
|
||||||
@@ -558,7 +565,8 @@ describe('BaseDataService', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
|
getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
|
||||||
requestUUIDs: ['request1', 'request2', 'request3']
|
requestUUIDs: ['request1', 'request2', 'request3'],
|
||||||
|
dependentRequestUUIDs: ['request4', 'request5']
|
||||||
}));
|
}));
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -570,6 +578,8 @@ describe('BaseDataService', () => {
|
|||||||
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1');
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1');
|
||||||
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2');
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2');
|
||||||
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3');
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3');
|
||||||
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request4');
|
||||||
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request5');
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -582,6 +592,8 @@ describe('BaseDataService', () => {
|
|||||||
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1');
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1');
|
||||||
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2');
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2');
|
||||||
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3');
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3');
|
||||||
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request4');
|
||||||
|
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request5');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should return an Observable that only emits true once all requests are stale', () => {
|
it('should return an Observable that only emits true once all requests are stale', () => {
|
||||||
@@ -591,9 +603,13 @@ describe('BaseDataService', () => {
|
|||||||
case 'request1':
|
case 'request1':
|
||||||
return cold('--(t|)', BOOLEAN);
|
return cold('--(t|)', BOOLEAN);
|
||||||
case 'request2':
|
case 'request2':
|
||||||
return cold('----(t|)', BOOLEAN);
|
|
||||||
case 'request3':
|
|
||||||
return cold('------(t|)', BOOLEAN);
|
return cold('------(t|)', BOOLEAN);
|
||||||
|
case 'request3':
|
||||||
|
return cold('---(t|)', BOOLEAN);
|
||||||
|
case 'request4':
|
||||||
|
return cold('-(t|)', BOOLEAN);
|
||||||
|
case 'request5':
|
||||||
|
return cold('----(t|)', BOOLEAN);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -607,9 +623,9 @@ describe('BaseDataService', () => {
|
|||||||
it('should only fire for the current state of the object (instead of tracking it)', () => {
|
it('should only fire for the current state of the object (instead of tracking it)', () => {
|
||||||
testScheduler.run(({ cold, flush }) => {
|
testScheduler.run(({ cold, flush }) => {
|
||||||
getByHrefSpy.and.returnValue(cold('a---b---c---', {
|
getByHrefSpy.and.returnValue(cold('a---b---c---', {
|
||||||
a: { requestUUIDs: ['request1'] }, // this is the state at the moment we're invalidating the cache
|
a: { requestUUIDs: ['request1'], dependentRequestUUIDs: [] }, // this is the state at the moment we're invalidating the cache
|
||||||
b: { requestUUIDs: ['request2'] }, // we shouldn't keep tracking the state
|
b: { requestUUIDs: ['request2'], dependentRequestUUIDs: [] }, // we shouldn't keep tracking the state
|
||||||
c: { requestUUIDs: ['request3'] }, // because we may invalidate when we shouldn't
|
c: { requestUUIDs: ['request3'], dependentRequestUUIDs: [] }, // because we may invalidate when we shouldn't
|
||||||
}));
|
}));
|
||||||
|
|
||||||
service.invalidateByHref('some-href');
|
service.invalidateByHref('some-href');
|
||||||
@@ -624,4 +640,42 @@ describe('BaseDataService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addDependency', () => {
|
||||||
|
let addDependencySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
addDependencySpy = spyOn(objectCache, 'addDependency');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call objectCache.addDependency with the object\'s self link', () => {
|
||||||
|
addDependencySpy.and.callFake((href$: Observable<string>, dependsOn$: Observable<string>) => {
|
||||||
|
observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => {
|
||||||
|
expect(href).toBe('object-href');
|
||||||
|
expect(dependsOn).toBe('dependsOnHref');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(service as any).addDependency(
|
||||||
|
createSuccessfulRemoteDataObject$({ _links: { self: { href: 'object-href' } } }),
|
||||||
|
observableOf('dependsOnHref')
|
||||||
|
);
|
||||||
|
expect(addDependencySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call objectCache.addDependency without an href if request failed', () => {
|
||||||
|
addDependencySpy.and.callFake((href$: Observable<string>, dependsOn$: Observable<string>) => {
|
||||||
|
observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => {
|
||||||
|
expect(href).toBe(undefined);
|
||||||
|
expect(dependsOn).toBe('dependsOnHref');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(service as any).addDependency(
|
||||||
|
createFailedRemoteDataObject$('something went wrong'),
|
||||||
|
observableOf('dependsOnHref')
|
||||||
|
);
|
||||||
|
expect(addDependencySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -23,6 +23,7 @@ import { PaginatedList } from '../paginated-list.model';
|
|||||||
import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
|
import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
|
||||||
import { ObjectCacheService } from '../../cache/object-cache.service';
|
import { ObjectCacheService } from '../../cache/object-cache.service';
|
||||||
import { HALDataService } from './hal-data-service.interface';
|
import { HALDataService } from './hal-data-service.interface';
|
||||||
|
import { getFirstCompletedRemoteData } from '../../shared/operators';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common functionality for data services.
|
* Common functionality for data services.
|
||||||
@@ -352,19 +353,55 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate a cached object by its href
|
* Shorthand method to add a dependency to a cached object
|
||||||
* @param href the href to invalidate
|
* ```
|
||||||
|
* const out$ = this.findByHref(...); // or another method that sends a request
|
||||||
|
* this.addDependency(out$, dependsOnHref);
|
||||||
|
* ```
|
||||||
|
* When {@link dependsOnHref$} is invalidated, {@link object$} will be invalidated as well.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @param object$ the cached object
|
||||||
|
* @param dependsOnHref$ the href of the object it should depend on
|
||||||
*/
|
*/
|
||||||
public invalidateByHref(href: string): Observable<boolean> {
|
protected addDependency(object$: Observable<RemoteData<T | PaginatedList<T>>>, dependsOnHref$: string | Observable<string>) {
|
||||||
|
this.objectCache.addDependency(
|
||||||
|
object$.pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
switchMap((rd: RemoteData<T>) => {
|
||||||
|
if (rd.hasSucceeded) {
|
||||||
|
return [rd.payload._links.self.href];
|
||||||
|
} else {
|
||||||
|
// undefined href will be skipped in objectCache.addDependency
|
||||||
|
return [undefined];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
dependsOnHref$
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate an existing DSpaceObject by marking all requests it is included in as stale
|
||||||
|
* @param href The self link of the object to be invalidated
|
||||||
|
* @return An Observable that will emit `true` once all requests are stale
|
||||||
|
*/
|
||||||
|
invalidateByHref(href: string): Observable<boolean> {
|
||||||
const done$ = new AsyncSubject<boolean>();
|
const done$ = new AsyncSubject<boolean>();
|
||||||
|
|
||||||
this.objectCache.getByHref(href).pipe(
|
this.objectCache.getByHref(href).pipe(
|
||||||
take(1),
|
take(1),
|
||||||
switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe(
|
switchMap((oce: ObjectCacheEntry) => {
|
||||||
mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)),
|
return observableFrom([
|
||||||
toArray(),
|
...oce.requestUUIDs,
|
||||||
)),
|
...oce.dependentRequestUUIDs
|
||||||
|
]).pipe(
|
||||||
|
mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)),
|
||||||
|
toArray(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
).subscribe(() => {
|
).subscribe(() => {
|
||||||
|
this.objectCache.removeDependents(href);
|
||||||
done$.next(true);
|
done$.next(true);
|
||||||
done$.complete();
|
done$.complete();
|
||||||
});
|
});
|
||||||
|
@@ -178,7 +178,12 @@ describe('PatchDataImpl', () => {
|
|||||||
|
|
||||||
describe('patch', () => {
|
describe('patch', () => {
|
||||||
const dso = {
|
const dso = {
|
||||||
uuid: 'dso-uuid'
|
uuid: 'dso-uuid',
|
||||||
|
_links: {
|
||||||
|
self: {
|
||||||
|
href: 'dso-href',
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const operations = [
|
const operations = [
|
||||||
Object.assign({
|
Object.assign({
|
||||||
@@ -188,14 +193,23 @@ describe('PatchDataImpl', () => {
|
|||||||
}) as Operation
|
}) as Operation
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach((done) => {
|
it('should send a PatchRequest', () => {
|
||||||
service.patch(dso, operations).subscribe(() => {
|
service.patch(dso, operations);
|
||||||
done();
|
expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest));
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send a PatchRequest', () => {
|
it('should invalidate the cached object if successfully patched', () => {
|
||||||
expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest));
|
spyOn(rdbService, 'buildFromRequestUUIDAndAwait');
|
||||||
|
spyOn(service, 'invalidateByHref');
|
||||||
|
|
||||||
|
service.patch(dso, operations);
|
||||||
|
|
||||||
|
expect(rdbService.buildFromRequestUUIDAndAwait).toHaveBeenCalled();
|
||||||
|
expect((rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[0]).toBe(requestService.generateRequestId());
|
||||||
|
const callback = (rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[1];
|
||||||
|
callback();
|
||||||
|
|
||||||
|
expect(service.invalidateByHref).toHaveBeenCalledWith('dso-href');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -101,7 +101,7 @@ export class PatchDataImpl<T extends CacheableObject> extends IdentifiableDataSe
|
|||||||
this.requestService.send(request);
|
this.requestService.send(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.rdbService.buildFromRequestUUID(requestId);
|
return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => this.invalidateByHref(object._links.self.href));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -2,7 +2,7 @@ import { AuthorizationDataService } from './authorization-data.service';
|
|||||||
import { SiteDataService } from '../site-data.service';
|
import { SiteDataService } from '../site-data.service';
|
||||||
import { Site } from '../../shared/site.model';
|
import { Site } from '../../shared/site.model';
|
||||||
import { EPerson } from '../../eperson/models/eperson.model';
|
import { EPerson } from '../../eperson/models/eperson.model';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
||||||
import { FeatureID } from './feature-id';
|
import { FeatureID } from './feature-id';
|
||||||
import { hasValue } from '../../../shared/empty.util';
|
import { hasValue } from '../../../shared/empty.util';
|
||||||
import { RequestParam } from '../../cache/models/request-param.model';
|
import { RequestParam } from '../../cache/models/request-param.model';
|
||||||
@@ -12,10 +12,12 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
|
|||||||
import { Feature } from '../../shared/feature.model';
|
import { Feature } from '../../shared/feature.model';
|
||||||
import { FindListOptions } from '../find-list-options.model';
|
import { FindListOptions } from '../find-list-options.model';
|
||||||
import { testSearchDataImplementation } from '../base/search-data.spec';
|
import { testSearchDataImplementation } from '../base/search-data.spec';
|
||||||
|
import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock';
|
||||||
|
|
||||||
describe('AuthorizationDataService', () => {
|
describe('AuthorizationDataService', () => {
|
||||||
let service: AuthorizationDataService;
|
let service: AuthorizationDataService;
|
||||||
let siteService: SiteDataService;
|
let siteService: SiteDataService;
|
||||||
|
let objectCache;
|
||||||
|
|
||||||
let site: Site;
|
let site: Site;
|
||||||
let ePerson: EPerson;
|
let ePerson: EPerson;
|
||||||
@@ -38,7 +40,8 @@ describe('AuthorizationDataService', () => {
|
|||||||
siteService = jasmine.createSpyObj('siteService', {
|
siteService = jasmine.createSpyObj('siteService', {
|
||||||
find: observableOf(site),
|
find: observableOf(site),
|
||||||
});
|
});
|
||||||
service = new AuthorizationDataService(requestService, undefined, undefined, undefined, siteService);
|
objectCache = getMockObjectCacheService();
|
||||||
|
service = new AuthorizationDataService(requestService, undefined, objectCache, undefined, siteService);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -110,6 +113,43 @@ describe('AuthorizationDataService', () => {
|
|||||||
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf), true, true);
|
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf), true, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('dependencies', () => {
|
||||||
|
let addDependencySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(service.searchBy as any).and.returnValue(observableOf('searchBy RD$'));
|
||||||
|
addDependencySpy = spyOn(service as any, 'addDependency');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a dependency on the objectUrl', (done) => {
|
||||||
|
addDependencySpy.and.callFake((href$: Observable<string>, dependsOn$: Observable<string>) => {
|
||||||
|
observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => {
|
||||||
|
expect(href).toBe('searchBy RD$');
|
||||||
|
expect(dependsOn).toBe('object-href');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
service.searchByObject(FeatureID.AdministratorOf, 'object-href').subscribe(() => {
|
||||||
|
expect(addDependencySpy).toHaveBeenCalled();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a dependency on the Site object if no objectUrl is given', (done) => {
|
||||||
|
addDependencySpy.and.callFake((object$: Observable<any>, dependsOn$: Observable<string>) => {
|
||||||
|
observableCombineLatest([object$, dependsOn$]).subscribe(([object, dependsOn]) => {
|
||||||
|
expect(object).toBe('searchBy RD$');
|
||||||
|
expect(dependsOn).toBe('test-site-href');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
service.searchByObject(FeatureID.AdministratorOf).subscribe(() => {
|
||||||
|
expect(addDependencySpy).toHaveBeenCalled();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isAuthorized', () => {
|
describe('isAuthorized', () => {
|
||||||
|
@@ -11,10 +11,10 @@ import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-
|
|||||||
import { RemoteData } from '../remote-data';
|
import { RemoteData } from '../remote-data';
|
||||||
import { PaginatedList } from '../paginated-list.model';
|
import { PaginatedList } from '../paginated-list.model';
|
||||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||||
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
|
import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
|
||||||
import { RequestParam } from '../../cache/models/request-param.model';
|
import { RequestParam } from '../../cache/models/request-param.model';
|
||||||
import { AuthorizationSearchParams } from './authorization-search-params';
|
import { AuthorizationSearchParams } from './authorization-search-params';
|
||||||
import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils';
|
import { oneAuthorizationMatchesFeature } from './authorization-utils';
|
||||||
import { FeatureID } from './feature-id';
|
import { FeatureID } from './feature-id';
|
||||||
import { getFirstCompletedRemoteData } from '../../shared/operators';
|
import { getFirstCompletedRemoteData } from '../../shared/operators';
|
||||||
import { FindListOptions } from '../find-list-options.model';
|
import { FindListOptions } from '../find-list-options.model';
|
||||||
@@ -96,12 +96,28 @@ export class AuthorizationDataService extends BaseDataService<Authorization> imp
|
|||||||
* {@link HALLink}s should be automatically resolved
|
* {@link HALLink}s should be automatically resolved
|
||||||
*/
|
*/
|
||||||
searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Authorization>[]): Observable<RemoteData<PaginatedList<Authorization>>> {
|
searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Authorization>[]): Observable<RemoteData<PaginatedList<Authorization>>> {
|
||||||
return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe(
|
const objectUrl$ = observableOf(objectUrl).pipe(
|
||||||
addSiteObjectUrlIfEmpty(this.siteService),
|
switchMap((url) => {
|
||||||
|
if (hasNoValue(url)) {
|
||||||
|
return this.siteService.find().pipe(
|
||||||
|
map((site) => site.self)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return observableOf(url);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const out$ = objectUrl$.pipe(
|
||||||
|
map((url: string) => new AuthorizationSearchParams(url, ePersonUuid, featureId)),
|
||||||
switchMap((params: AuthorizationSearchParams) => {
|
switchMap((params: AuthorizationSearchParams) => {
|
||||||
return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.addDependency(out$, objectUrl$);
|
||||||
|
|
||||||
|
return out$;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -307,7 +307,7 @@ describe('EPersonDataService', () => {
|
|||||||
it('should sent a patch request with an uuid, token and new password to the epersons endpoint', () => {
|
it('should sent a patch request with an uuid, token and new password to the epersons endpoint', () => {
|
||||||
service.patchPasswordWithToken('test-uuid', 'test-token', 'test-password');
|
service.patchPasswordWithToken('test-uuid', 'test-token', 'test-password');
|
||||||
|
|
||||||
const operation = Object.assign({ op: 'add', path: '/password', value: 'test-password' });
|
const operation = Object.assign({ op: 'add', path: '/password', value: { new_password: 'test-password' } });
|
||||||
const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/test-uuid?token=test-token', [operation]);
|
const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/test-uuid?token=test-token', [operation]);
|
||||||
|
|
||||||
expect(requestService.send).toHaveBeenCalledWith(expected);
|
expect(requestService.send).toHaveBeenCalledWith(expected);
|
||||||
|
@@ -3,7 +3,10 @@ import { createSelector, select, Store } from '@ngrx/store';
|
|||||||
import { Operation } from 'fast-json-patch';
|
import { Operation } from 'fast-json-patch';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { find, map, take } from 'rxjs/operators';
|
import { find, map, take } from 'rxjs/operators';
|
||||||
import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../access-control/epeople-registry/epeople-registry.actions';
|
import {
|
||||||
|
EPeopleRegistryCancelEPersonAction,
|
||||||
|
EPeopleRegistryEditEPersonAction
|
||||||
|
} from '../../access-control/epeople-registry/epeople-registry.actions';
|
||||||
import { EPeopleRegistryState } from '../../access-control/epeople-registry/epeople-registry.reducers';
|
import { EPeopleRegistryState } from '../../access-control/epeople-registry/epeople-registry.reducers';
|
||||||
import { AppState } from '../../app.reducer';
|
import { AppState } from '../../app.reducer';
|
||||||
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
||||||
@@ -318,7 +321,7 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
|
|||||||
patchPasswordWithToken(uuid: string, token: string, password: string): Observable<RemoteData<EPerson>> {
|
patchPasswordWithToken(uuid: string, token: string, password: string): Observable<RemoteData<EPerson>> {
|
||||||
const requestId = this.requestService.generateRequestId();
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
const operation = Object.assign({ op: 'add', path: '/password', value: password });
|
const operation = Object.assign({ op: 'add', path: '/password', value: { 'new_password': password } });
|
||||||
|
|
||||||
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
|
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
|
||||||
map((endpoint: string) => this.getIDHref(endpoint, uuid)),
|
map((endpoint: string) => this.getIDHref(endpoint, uuid)),
|
||||||
|
@@ -26,10 +26,9 @@ import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock';
|
|||||||
import { createPaginatedList, createRequestEntry$ } from '../../shared/testing/utils.test';
|
import { createPaginatedList, createRequestEntry$ } from '../../shared/testing/utils.test';
|
||||||
import { CoreState } from '../core-state.model';
|
import { CoreState } from '../core-state.model';
|
||||||
import { FindListOptions } from '../data/find-list-options.model';
|
import { FindListOptions } from '../data/find-list-options.model';
|
||||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
|
||||||
import { getMockLinkService } from '../../shared/mocks/link-service.mock';
|
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { ObjectCacheEntry } from '../cache/object-cache.reducer';
|
import { ObjectCacheEntry } from '../cache/object-cache.reducer';
|
||||||
|
import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock';
|
||||||
|
|
||||||
describe('GroupDataService', () => {
|
describe('GroupDataService', () => {
|
||||||
let service: GroupDataService;
|
let service: GroupDataService;
|
||||||
@@ -42,7 +41,7 @@ describe('GroupDataService', () => {
|
|||||||
let groups$;
|
let groups$;
|
||||||
let halService;
|
let halService;
|
||||||
let rdbService;
|
let rdbService;
|
||||||
let objectCache: ObjectCacheService;
|
let objectCache;
|
||||||
function init() {
|
function init() {
|
||||||
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
|
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
|
||||||
groupsEndpoint = `${restEndpointURL}/groups`;
|
groupsEndpoint = `${restEndpointURL}/groups`;
|
||||||
@@ -50,7 +49,7 @@ describe('GroupDataService', () => {
|
|||||||
groups$ = createSuccessfulRemoteDataObject$(createPaginatedList(groups));
|
groups$ = createSuccessfulRemoteDataObject$(createPaginatedList(groups));
|
||||||
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ });
|
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ });
|
||||||
halService = new HALEndpointServiceStub(restEndpointURL);
|
halService = new HALEndpointServiceStub(restEndpointURL);
|
||||||
objectCache = new ObjectCacheService(store, getMockLinkService());
|
objectCache = getMockObjectCacheService();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -114,8 +113,9 @@ describe('GroupDataService', () => {
|
|||||||
|
|
||||||
describe('addSubGroupToGroup', () => {
|
describe('addSubGroupToGroup', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
|
objectCache.getByHref.and.returnValue(observableOf({
|
||||||
requestUUIDs: ['request1', 'request2']
|
requestUUIDs: ['request1', 'request2'],
|
||||||
|
dependentRequestUUIDs: [],
|
||||||
} as ObjectCacheEntry));
|
} as ObjectCacheEntry));
|
||||||
spyOn((service as any).deleteData, 'invalidateByHref');
|
spyOn((service as any).deleteData, 'invalidateByHref');
|
||||||
service.addSubGroupToGroup(GroupMock, GroupMock2).subscribe();
|
service.addSubGroupToGroup(GroupMock, GroupMock2).subscribe();
|
||||||
@@ -143,8 +143,9 @@ describe('GroupDataService', () => {
|
|||||||
|
|
||||||
describe('deleteSubGroupFromGroup', () => {
|
describe('deleteSubGroupFromGroup', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
|
objectCache.getByHref.and.returnValue(observableOf({
|
||||||
requestUUIDs: ['request1', 'request2']
|
requestUUIDs: ['request1', 'request2'],
|
||||||
|
dependentRequestUUIDs: [],
|
||||||
} as ObjectCacheEntry));
|
} as ObjectCacheEntry));
|
||||||
spyOn((service as any).deleteData, 'invalidateByHref');
|
spyOn((service as any).deleteData, 'invalidateByHref');
|
||||||
service.deleteSubGroupFromGroup(GroupMock, GroupMock2).subscribe();
|
service.deleteSubGroupFromGroup(GroupMock, GroupMock2).subscribe();
|
||||||
@@ -168,8 +169,9 @@ describe('GroupDataService', () => {
|
|||||||
|
|
||||||
describe('addMemberToGroup', () => {
|
describe('addMemberToGroup', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
|
objectCache.getByHref.and.returnValue(observableOf({
|
||||||
requestUUIDs: ['request1', 'request2']
|
requestUUIDs: ['request1', 'request2'],
|
||||||
|
dependentRequestUUIDs: [],
|
||||||
} as ObjectCacheEntry));
|
} as ObjectCacheEntry));
|
||||||
spyOn((service as any).deleteData, 'invalidateByHref');
|
spyOn((service as any).deleteData, 'invalidateByHref');
|
||||||
service.addMemberToGroup(GroupMock, EPersonMock2).subscribe();
|
service.addMemberToGroup(GroupMock, EPersonMock2).subscribe();
|
||||||
@@ -198,8 +200,9 @@ describe('GroupDataService', () => {
|
|||||||
|
|
||||||
describe('deleteMemberFromGroup', () => {
|
describe('deleteMemberFromGroup', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
|
objectCache.getByHref.and.returnValue(observableOf({
|
||||||
requestUUIDs: ['request1', 'request2']
|
requestUUIDs: ['request1', 'request2'],
|
||||||
|
dependentRequestUUIDs: [],
|
||||||
} as ObjectCacheEntry));
|
} as ObjectCacheEntry));
|
||||||
spyOn((service as any).deleteData, 'invalidateByHref');
|
spyOn((service as any).deleteData, 'invalidateByHref');
|
||||||
service.deleteMemberFromGroup(GroupMock, EPersonMock).subscribe();
|
service.deleteMemberFromGroup(GroupMock, EPersonMock).subscribe();
|
||||||
|
@@ -2,17 +2,25 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { BrowserHardRedirectService } from './browser-hard-redirect.service';
|
import { BrowserHardRedirectService } from './browser-hard-redirect.service';
|
||||||
|
|
||||||
describe('BrowserHardRedirectService', () => {
|
describe('BrowserHardRedirectService', () => {
|
||||||
const origin = 'https://test-host.com:4000';
|
let origin: string;
|
||||||
const mockLocation = {
|
let mockLocation: Location;
|
||||||
href: undefined,
|
let service: BrowserHardRedirectService;
|
||||||
pathname: '/pathname',
|
|
||||||
search: '/search',
|
|
||||||
origin
|
|
||||||
} as Location;
|
|
||||||
|
|
||||||
const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
origin = 'https://test-host.com:4000';
|
||||||
|
mockLocation = {
|
||||||
|
href: undefined,
|
||||||
|
pathname: '/pathname',
|
||||||
|
search: '/search',
|
||||||
|
origin,
|
||||||
|
replace: (url: string) => {
|
||||||
|
mockLocation.href = url;
|
||||||
|
}
|
||||||
|
} as Location;
|
||||||
|
spyOn(mockLocation, 'replace');
|
||||||
|
|
||||||
|
service = new BrowserHardRedirectService(mockLocation);
|
||||||
|
|
||||||
TestBed.configureTestingModule({});
|
TestBed.configureTestingModule({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,8 +36,8 @@ describe('BrowserHardRedirectService', () => {
|
|||||||
service.redirect(redirect);
|
service.redirect(redirect);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update the location', () => {
|
it('should call location.replace with the new url', () => {
|
||||||
expect(mockLocation.href).toEqual(redirect);
|
expect(mockLocation.replace).toHaveBeenCalledWith(redirect);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -24,7 +24,7 @@ export class BrowserHardRedirectService extends HardRedirectService {
|
|||||||
* @param url
|
* @param url
|
||||||
*/
|
*/
|
||||||
redirect(url: string) {
|
redirect(url: string) {
|
||||||
this.location.href = url;
|
this.location.replace(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0">
|
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0">
|
||||||
<ds-search-navbar></ds-search-navbar>
|
<ds-themed-search-navbar></ds-themed-search-navbar>
|
||||||
<ds-lang-switch></ds-lang-switch>
|
<ds-lang-switch></ds-lang-switch>
|
||||||
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
|
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
|
||||||
<ds-impersonate-navbar></ds-impersonate-navbar>
|
<ds-impersonate-navbar></ds-impersonate-navbar>
|
||||||
|
@@ -10,6 +10,9 @@ import { StatisticsModule } from '../statistics/statistics.module';
|
|||||||
import { ThemedHomeNewsComponent } from './home-news/themed-home-news.component';
|
import { ThemedHomeNewsComponent } from './home-news/themed-home-news.component';
|
||||||
import { ThemedHomePageComponent } from './themed-home-page.component';
|
import { ThemedHomePageComponent } from './themed-home-page.component';
|
||||||
import { RecentItemListComponent } from './recent-item-list/recent-item-list.component';
|
import { RecentItemListComponent } from './recent-item-list/recent-item-list.component';
|
||||||
|
import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module';
|
||||||
|
import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module';
|
||||||
|
|
||||||
const DECLARATIONS = [
|
const DECLARATIONS = [
|
||||||
HomePageComponent,
|
HomePageComponent,
|
||||||
ThemedHomePageComponent,
|
ThemedHomePageComponent,
|
||||||
@@ -22,7 +25,9 @@ const DECLARATIONS = [
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
SharedModule,
|
SharedModule.withEntryComponents(),
|
||||||
|
JournalEntitiesModule.withEntryComponents(),
|
||||||
|
ResearchEntitiesModule.withEntryComponents(),
|
||||||
HomePageRoutingModule,
|
HomePageRoutingModule,
|
||||||
StatisticsModule.forRoot()
|
StatisticsModule.forRoot()
|
||||||
],
|
],
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<ng-container *ngVar="(itemRD$ | async) as itemRD">
|
<ng-container *ngVar="(itemRD$ | async) as itemRD">
|
||||||
<div class="mt-4" *ngIf="itemRD?.hasSucceeded && itemRD?.payload?.page.length > 0" @fadeIn>
|
<div class="mt-4" [ngClass]="placeholderFontClass" *ngIf="itemRD?.hasSucceeded && itemRD?.payload?.page.length > 0" @fadeIn>
|
||||||
<div class="d-flex flex-row border-bottom mb-4 pb-4 ng-tns-c416-2"></div>
|
<div class="d-flex flex-row border-bottom mb-4 pb-4 ng-tns-c416-2"></div>
|
||||||
<h2> {{'home.recent-submissions.head' | translate}}</h2>
|
<h2> {{'home.recent-submissions.head' | translate}}</h2>
|
||||||
<div class="my-4" *ngFor="let item of itemRD?.payload?.page">
|
<div class="my-4" *ngFor="let item of itemRD?.payload?.page">
|
||||||
@@ -12,4 +12,4 @@
|
|||||||
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.recent-submissions' | translate}}"></ds-error>
|
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.recent-submissions' | translate}}"></ds-error>
|
||||||
<ds-loading *ngIf="!itemRD || itemRD.isLoading" message="{{'loading.recent-submissions' | translate}}">
|
<ds-loading *ngIf="!itemRD || itemRD.isLoading" message="{{'loading.recent-submissions' | translate}}">
|
||||||
</ds-loading>
|
</ds-loading>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@@ -10,8 +10,11 @@ import { SearchConfigurationService } from '../../core/shared/search/search-conf
|
|||||||
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
|
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
|
||||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||||
import { ViewMode } from 'src/app/core/shared/view-mode.model';
|
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { APP_CONFIG } from '../../../config/app-config.interface';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { PLATFORM_ID } from '@angular/core';
|
||||||
|
|
||||||
describe('RecentItemListComponent', () => {
|
describe('RecentItemListComponent', () => {
|
||||||
let component: RecentItemListComponent;
|
let component: RecentItemListComponent;
|
||||||
let fixture: ComponentFixture<RecentItemListComponent>;
|
let fixture: ComponentFixture<RecentItemListComponent>;
|
||||||
@@ -42,6 +45,8 @@ describe('RecentItemListComponent', () => {
|
|||||||
{ provide: SearchService, useValue: searchServiceStub },
|
{ provide: SearchService, useValue: searchServiceStub },
|
||||||
{ provide: PaginationService, useValue: paginationService },
|
{ provide: PaginationService, useValue: paginationService },
|
||||||
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
|
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
|
||||||
|
{ provide: APP_CONFIG, useValue: environment },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'browser' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, ElementRef, Inject, OnInit, PLATFORM_ID } from '@angular/core';
|
||||||
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
|
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
|
||||||
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
|
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
|
||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
@@ -11,12 +11,13 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options
|
|||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||||
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
|
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
|
||||||
import {
|
import { toDSpaceObjectListRD } from '../../core/shared/operators';
|
||||||
toDSpaceObjectListRD
|
import { Observable } from 'rxjs';
|
||||||
} from '../../core/shared/operators';
|
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
import {
|
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
|
||||||
Observable,
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
} from 'rxjs';
|
import { setPlaceHolderAttributes } from '../../shared/utils/object-list-utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-recent-item-list',
|
selector: 'ds-recent-item-list',
|
||||||
templateUrl: './recent-item-list.component.html',
|
templateUrl: './recent-item-list.component.html',
|
||||||
@@ -31,14 +32,22 @@ export class RecentItemListComponent implements OnInit {
|
|||||||
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
|
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||||
paginationConfig: PaginationComponentOptions;
|
paginationConfig: PaginationComponentOptions;
|
||||||
sortConfig: SortOptions;
|
sortConfig: SortOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The view-mode we're currently on
|
* The view-mode we're currently on
|
||||||
* @type {ViewMode}
|
* @type {ViewMode}
|
||||||
*/
|
*/
|
||||||
viewMode = ViewMode.ListElement;
|
viewMode = ViewMode.ListElement;
|
||||||
constructor(private searchService: SearchService,
|
|
||||||
|
private _placeholderFontClass: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private searchService: SearchService,
|
||||||
private paginationService: PaginationService,
|
private paginationService: PaginationService,
|
||||||
public searchConfigurationService: SearchConfigurationService
|
public searchConfigurationService: SearchConfigurationService,
|
||||||
|
protected elementRef: ElementRef,
|
||||||
|
@Inject(APP_CONFIG) private appConfig: AppConfig,
|
||||||
|
@Inject(PLATFORM_ID) private platformId: Object,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
this.paginationConfig = Object.assign(new PaginationComponentOptions(), {
|
this.paginationConfig = Object.assign(new PaginationComponentOptions(), {
|
||||||
@@ -50,16 +59,29 @@ export class RecentItemListComponent implements OnInit {
|
|||||||
this.sortConfig = new SortOptions(environment.homePage.recentSubmissions.sortField, SortDirection.DESC);
|
this.sortConfig = new SortOptions(environment.homePage.recentSubmissions.sortField, SortDirection.DESC);
|
||||||
}
|
}
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
const linksToFollow: FollowLinkConfig<Item>[] = [];
|
||||||
|
if (this.appConfig.browseBy.showThumbnails) {
|
||||||
|
linksToFollow.push(followLink('thumbnail'));
|
||||||
|
}
|
||||||
|
|
||||||
this.itemRD$ = this.searchService.search(
|
this.itemRD$ = this.searchService.search(
|
||||||
new PaginatedSearchOptions({
|
new PaginatedSearchOptions({
|
||||||
pagination: this.paginationConfig,
|
pagination: this.paginationConfig,
|
||||||
sort: this.sortConfig,
|
sort: this.sortConfig,
|
||||||
}),
|
}),
|
||||||
).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>;
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
...linksToFollow,
|
||||||
|
).pipe(
|
||||||
|
toDSpaceObjectListRD()
|
||||||
|
) as Observable<RemoteData<PaginatedList<Item>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.paginationService.clearPagination(this.paginationConfig.id);
|
this.paginationService.clearPagination(this.paginationConfig.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoadMore(): void {
|
onLoadMore(): void {
|
||||||
this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, ['search'], {
|
this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, ['search'], {
|
||||||
sortField: environment.homePage.recentSubmissions.sortField,
|
sortField: environment.homePage.recentSubmissions.sortField,
|
||||||
@@ -68,5 +90,17 @@ export class RecentItemListComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get placeholderFontClass(): string {
|
||||||
|
if (this._placeholderFontClass === undefined) {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
const width = this.elementRef.nativeElement.offsetWidth;
|
||||||
|
this._placeholderFontClass = setPlaceHolderAttributes(width);
|
||||||
|
} else {
|
||||||
|
this._placeholderFontClass = 'hide-placeholder-text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._placeholderFontClass;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -175,14 +175,18 @@ describe('TopLevelCommunityList Component', () => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display a list of top-communities', () => {
|
|
||||||
const subComList = fixture.debugElement.queryAll(By.css('li'));
|
|
||||||
|
|
||||||
expect(subComList.length).toEqual(5);
|
it('should display a list of top-communities', () => {
|
||||||
expect(subComList[0].nativeElement.textContent).toContain('TopCommunity 1');
|
waitForAsync(() => {
|
||||||
expect(subComList[1].nativeElement.textContent).toContain('TopCommunity 2');
|
const subComList = fixture.debugElement.queryAll(By.css('li'));
|
||||||
expect(subComList[2].nativeElement.textContent).toContain('TopCommunity 3');
|
|
||||||
expect(subComList[3].nativeElement.textContent).toContain('TopCommunity 4');
|
expect(subComList.length).toEqual(5);
|
||||||
expect(subComList[4].nativeElement.textContent).toContain('TopCommunity 5');
|
expect(subComList[0].nativeElement.textContent).toContain('TopCommunity 1');
|
||||||
|
expect(subComList[1].nativeElement.textContent).toContain('TopCommunity 2');
|
||||||
|
expect(subComList[2].nativeElement.textContent).toContain('TopCommunity 3');
|
||||||
|
expect(subComList[3].nativeElement.textContent).toContain('TopCommunity 4');
|
||||||
|
expect(subComList[4].nativeElement.textContent).toContain('TopCommunity 5');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -26,7 +26,7 @@
|
|||||||
<td class="w-100">
|
<td class="w-100">
|
||||||
<div class="value-field">
|
<div class="value-field">
|
||||||
<div *ngIf="!(editable | async)">
|
<div *ngIf="!(editable | async)">
|
||||||
<span class="dont-break-out">{{metadata?.value}}</span>
|
<span class="dont-break-out preserve-line-breaks">{{metadata?.value}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="(editable | async)" class="field-container">
|
<div *ngIf="(editable | async)" class="field-container">
|
||||||
<textarea class="form-control" type="textarea" attr.aria-labelledby="fieldValue" [(ngModel)]="metadata.value" [dsDebounce]
|
<textarea class="form-control" type="textarea" attr.aria-labelledby="fieldValue" [(ngModel)]="metadata.value" [dsDebounce]
|
||||||
|
@@ -1,5 +1,18 @@
|
|||||||
<ds-metadata-field-wrapper [label]="label | translate">
|
<ds-metadata-field-wrapper [label]="label | translate">
|
||||||
<span class="dont-break-out" *ngFor="let mdValue of mdValues; let last=last;">
|
<ng-container *ngFor="let mdValue of mdValues; let last=last;">
|
||||||
{{mdValue.value}}<span *ngIf="!last" [innerHTML]="separator"></span>
|
<ng-container *ngTemplateOutlet="(renderMarkdown ? markdown : simple); context: {value: mdValue.value, classes: 'dont-break-out preserve-line-breaks'}">
|
||||||
</span>
|
</ng-container>
|
||||||
|
<span class="separator" *ngIf="!last" [innerHTML]="separator"></span>
|
||||||
|
</ng-container>
|
||||||
</ds-metadata-field-wrapper>
|
</ds-metadata-field-wrapper>
|
||||||
|
|
||||||
|
<ng-template #markdown let-value="value" let-classes="classes">
|
||||||
|
<span class="{{classes}}" [innerHTML]="value | dsMarkdown | async">
|
||||||
|
</span>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #simple let-value="value" let-classes="classes">
|
||||||
|
<span class="{{classes}}">
|
||||||
|
{{value}}
|
||||||
|
</span>
|
||||||
|
</ng-template>
|
||||||
|
@@ -58,7 +58,7 @@ describe('MetadataValuesComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should contain separators equal to the amount of metadata values minus one', () => {
|
it('should contain separators equal to the amount of metadata values minus one', () => {
|
||||||
const separators = fixture.debugElement.queryAll(By.css('span>span'));
|
const separators = fixture.debugElement.queryAll(By.css('span.separator'));
|
||||||
expect(separators.length).toBe(mockMetadata.length - 1);
|
expect(separators.length).toBe(mockMetadata.length - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
import { MetadataValue } from '../../../core/shared/metadata.models';
|
import { MetadataValue } from '../../../core/shared/metadata.models';
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.
|
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.
|
||||||
@@ -10,7 +11,7 @@ import { MetadataValue } from '../../../core/shared/metadata.models';
|
|||||||
styleUrls: ['./metadata-values.component.scss'],
|
styleUrls: ['./metadata-values.component.scss'],
|
||||||
templateUrl: './metadata-values.component.html'
|
templateUrl: './metadata-values.component.html'
|
||||||
})
|
})
|
||||||
export class MetadataValuesComponent {
|
export class MetadataValuesComponent implements OnChanges {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The metadata values to display
|
* The metadata values to display
|
||||||
@@ -27,4 +28,19 @@ export class MetadataValuesComponent {
|
|||||||
*/
|
*/
|
||||||
@Input() label: string;
|
@Input() label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the {@link MarkdownPipe} should be used to render these metadata values.
|
||||||
|
* This will only have effect if {@link MarkdownConfig#enabled} is true.
|
||||||
|
* Mathjax will only be rendered if {@link MarkdownConfig#mathjax} is true.
|
||||||
|
*/
|
||||||
|
@Input() enableMarkdown = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This variable will be true if both {@link environment.markdown.enabled} and {@link enableMarkdown} are true.
|
||||||
|
*/
|
||||||
|
renderMarkdown;
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
this.renderMarkdown = !!environment.markdown.enabled && this.enableMarkdown;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,7 @@ import { createPaginatedList } from '../../shared/testing/utils.test';
|
|||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { MiradorViewerService } from './mirador-viewer.service';
|
import { MiradorViewerService } from './mirador-viewer.service';
|
||||||
import { HostWindowService } from '../../shared/host-window.service';
|
import { HostWindowService } from '../../shared/host-window.service';
|
||||||
|
import { BundleDataService } from '../../core/data/bundle-data.service';
|
||||||
|
|
||||||
|
|
||||||
function getItem(metadata: MetadataMap) {
|
function getItem(metadata: MetadataMap) {
|
||||||
@@ -46,6 +47,7 @@ describe('MiradorViewerComponent with search', () => {
|
|||||||
declarations: [MiradorViewerComponent],
|
declarations: [MiradorViewerComponent],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: BitstreamDataService, useValue: {} },
|
{ provide: BitstreamDataService, useValue: {} },
|
||||||
|
{ provide: BundleDataService, useValue: {} },
|
||||||
{ provide: HostWindowService, useValue: mockHostWindowService }
|
{ provide: HostWindowService, useValue: mockHostWindowService }
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
@@ -108,6 +110,7 @@ describe('MiradorViewerComponent with multiple images', () => {
|
|||||||
declarations: [MiradorViewerComponent],
|
declarations: [MiradorViewerComponent],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: BitstreamDataService, useValue: {} },
|
{ provide: BitstreamDataService, useValue: {} },
|
||||||
|
{ provide: BundleDataService, useValue: {} },
|
||||||
{ provide: HostWindowService, useValue: mockHostWindowService }
|
{ provide: HostWindowService, useValue: mockHostWindowService }
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
@@ -167,6 +170,7 @@ describe('MiradorViewerComponent with a single image', () => {
|
|||||||
declarations: [MiradorViewerComponent],
|
declarations: [MiradorViewerComponent],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: BitstreamDataService, useValue: {} },
|
{ provide: BitstreamDataService, useValue: {} },
|
||||||
|
{ provide: BundleDataService, useValue: {} },
|
||||||
{ provide: HostWindowService, useValue: mockHostWindowService }
|
{ provide: HostWindowService, useValue: mockHostWindowService }
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
@@ -225,6 +229,7 @@ describe('MiradorViewerComponent in development mode', () => {
|
|||||||
set: {
|
set: {
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: MiradorViewerService, useValue: viewerService },
|
{ provide: MiradorViewerService, useValue: viewerService },
|
||||||
|
{ provide: BundleDataService, useValue: {} },
|
||||||
{ provide: HostWindowService, useValue: mockHostWindowService }
|
{ provide: HostWindowService, useValue: mockHostWindowService }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,7 @@ import { map, take } from 'rxjs/operators';
|
|||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { MiradorViewerService } from './mirador-viewer.service';
|
import { MiradorViewerService } from './mirador-viewer.service';
|
||||||
import { HostWindowService, WidthCategory } from '../../shared/host-window.service';
|
import { HostWindowService, WidthCategory } from '../../shared/host-window.service';
|
||||||
|
import { BundleDataService } from '../../core/data/bundle-data.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-mirador-viewer',
|
selector: 'ds-mirador-viewer',
|
||||||
@@ -55,6 +56,7 @@ export class MiradorViewerComponent implements OnInit {
|
|||||||
constructor(private sanitizer: DomSanitizer,
|
constructor(private sanitizer: DomSanitizer,
|
||||||
private viewerService: MiradorViewerService,
|
private viewerService: MiradorViewerService,
|
||||||
private bitstreamDataService: BitstreamDataService,
|
private bitstreamDataService: BitstreamDataService,
|
||||||
|
private bundleDataService: BundleDataService,
|
||||||
private hostWindowService: HostWindowService,
|
private hostWindowService: HostWindowService,
|
||||||
@Inject(PLATFORM_ID) private platformId: any) {
|
@Inject(PLATFORM_ID) private platformId: any) {
|
||||||
}
|
}
|
||||||
@@ -107,10 +109,10 @@ export class MiradorViewerComponent implements OnInit {
|
|||||||
this.notMobile = !(category === WidthCategory.XS || category === WidthCategory.SM);
|
this.notMobile = !(category === WidthCategory.XS || category === WidthCategory.SM);
|
||||||
});
|
});
|
||||||
|
|
||||||
// We need to set the multi property to true if the
|
// Set the multi property. The default mirador configuration adds a right
|
||||||
// item is searchable or when the ORIGINAL bundle contains more
|
// thumbnail navigation panel to the viewer when multi is 'true'.
|
||||||
// than 1 image. (The multi property determines whether the
|
|
||||||
// Mirador side thumbnail navigation panel is shown.)
|
// Set the multi property to 'true' if the item is searchable.
|
||||||
if (this.searchable) {
|
if (this.searchable) {
|
||||||
this.multi = true;
|
this.multi = true;
|
||||||
const observable = of('');
|
const observable = of('');
|
||||||
@@ -120,8 +122,12 @@ export class MiradorViewerComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Sets the multi value based on the image count.
|
// Set the multi property based on the image count in IIIF-eligible bundles.
|
||||||
this.iframeViewerUrl = this.viewerService.getImageCount(this.object, this.bitstreamDataService).pipe(
|
// Any count greater than 1 sets the value to 'true'.
|
||||||
|
this.iframeViewerUrl = this.viewerService.getImageCount(
|
||||||
|
this.object,
|
||||||
|
this.bitstreamDataService,
|
||||||
|
this.bundleDataService).pipe(
|
||||||
map(c => {
|
map(c => {
|
||||||
if (c > 1) {
|
if (c > 1) {
|
||||||
this.multi = true;
|
this.multi = true;
|
||||||
|
@@ -1,14 +1,18 @@
|
|||||||
import { Injectable, isDevMode } from '@angular/core';
|
import { Injectable, isDevMode } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Item } from '../../core/shared/item.model';
|
import { Item } from '../../core/shared/item.model';
|
||||||
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
|
import {
|
||||||
import { last, map, switchMap } from 'rxjs/operators';
|
getFirstCompletedRemoteData,
|
||||||
|
} from '../../core/shared/operators';
|
||||||
|
import { filter, last, map, mergeMap, switchMap } from 'rxjs/operators';
|
||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
import { PaginatedList } from '../../core/data/paginated-list.model';
|
import { PaginatedList } from '../../core/data/paginated-list.model';
|
||||||
import { Bitstream } from '../../core/shared/bitstream.model';
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
|
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
|
||||||
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||||
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
|
import { Bundle } from '../../core/shared/bundle.model';
|
||||||
|
import { BundleDataService } from '../../core/data/bundle-data.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MiradorViewerService {
|
export class MiradorViewerService {
|
||||||
@@ -26,32 +30,64 @@ export class MiradorViewerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns observable of the number of images in the ORIGINAL bundle
|
* Returns observable of the number of images found in eligible IIIF bundles. Checks
|
||||||
|
* the mimetype of the first 5 bitstreams in each bundle.
|
||||||
* @param item
|
* @param item
|
||||||
* @param bitstreamDataService
|
* @param bitstreamDataService
|
||||||
|
* @param bundleDataService
|
||||||
|
* @returns the total image count
|
||||||
*/
|
*/
|
||||||
getImageCount(item: Item, bitstreamDataService: BitstreamDataService): Observable<number> {
|
getImageCount(item: Item, bitstreamDataService: BitstreamDataService, bundleDataService: BundleDataService):
|
||||||
let count = 0;
|
Observable<number> {
|
||||||
return bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL', {
|
let count = 0;
|
||||||
currentPage: 1,
|
return bundleDataService.findAllByItem(item).pipe(
|
||||||
elementsPerPage: 10
|
getFirstCompletedRemoteData(),
|
||||||
}, true, true, ...this.LINKS_TO_FOLLOW)
|
map((bundlesRD: RemoteData<PaginatedList<Bundle>>) => {
|
||||||
.pipe(
|
return bundlesRD.payload;
|
||||||
getFirstCompletedRemoteData(),
|
}),
|
||||||
map((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => bitstreamsRD.payload),
|
map((paginatedList: PaginatedList<Bundle>) => paginatedList.page),
|
||||||
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
|
switchMap((bundles: Bundle[]) => bundles),
|
||||||
switchMap((bitstreams: Bitstream[]) => bitstreams),
|
filter((b: Bundle) => this.isIiifBundle(b.name)),
|
||||||
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
|
mergeMap((bundle: Bundle) => {
|
||||||
getFirstSucceededRemoteDataPayload(),
|
return bitstreamDataService.findAllByItemAndBundleName(item, bundle.name, {
|
||||||
map((format: BitstreamFormat) => format)
|
currentPage: 1,
|
||||||
)),
|
elementsPerPage: 5
|
||||||
map((format: BitstreamFormat) => {
|
}, true, true, ...this.LINKS_TO_FOLLOW).pipe(
|
||||||
if (format.mimetype.includes('image')) {
|
getFirstCompletedRemoteData(),
|
||||||
count++;
|
map((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => {
|
||||||
}
|
return bitstreamsRD.payload;
|
||||||
return count;
|
}),
|
||||||
}),
|
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
|
||||||
last()
|
switchMap((bitstreams: Bitstream[]) => bitstreams),
|
||||||
|
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
map((formatRD: RemoteData<BitstreamFormat>) => {
|
||||||
|
return formatRD.payload;
|
||||||
|
}),
|
||||||
|
map((format: BitstreamFormat) => {
|
||||||
|
if (format.mimetype.includes('image')) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
last()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isIiifBundle(bundleName: string): boolean {
|
||||||
|
return !(
|
||||||
|
bundleName === 'OtherContent' ||
|
||||||
|
bundleName === 'LICENSE' ||
|
||||||
|
bundleName === 'THUMBNAIL' ||
|
||||||
|
bundleName === 'TEXT' ||
|
||||||
|
bundleName === 'METADATA' ||
|
||||||
|
bundleName === 'CC-LICENSE' ||
|
||||||
|
bundleName === 'BRANDED_PREVIEW'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@ import { ItemPageAbstractFieldComponent } from './item-page-abstract-field.compo
|
|||||||
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
|
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
|
||||||
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
|
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
|
||||||
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
|
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
|
||||||
|
import { SharedModule } from '../../../../../shared/shared.module';
|
||||||
|
|
||||||
let comp: ItemPageAbstractFieldComponent;
|
let comp: ItemPageAbstractFieldComponent;
|
||||||
let fixture: ComponentFixture<ItemPageAbstractFieldComponent>;
|
let fixture: ComponentFixture<ItemPageAbstractFieldComponent>;
|
||||||
@@ -15,12 +16,15 @@ const mockValue = 'test value';
|
|||||||
describe('ItemPageAbstractFieldComponent', () => {
|
describe('ItemPageAbstractFieldComponent', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot({
|
imports: [
|
||||||
loader: {
|
TranslateModule.forRoot({
|
||||||
provide: TranslateLoader,
|
loader: {
|
||||||
useClass: TranslateLoaderMock
|
provide: TranslateLoader,
|
||||||
}
|
useClass: TranslateLoaderMock
|
||||||
})],
|
}
|
||||||
|
}),
|
||||||
|
SharedModule,
|
||||||
|
],
|
||||||
declarations: [ItemPageAbstractFieldComponent, MetadataValuesComponent],
|
declarations: [ItemPageAbstractFieldComponent, MetadataValuesComponent],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
}).overrideComponent(ItemPageAbstractFieldComponent, {
|
}).overrideComponent(ItemPageAbstractFieldComponent, {
|
||||||
|
@@ -36,4 +36,8 @@ export class ItemPageAbstractFieldComponent extends ItemPageFieldComponent {
|
|||||||
*/
|
*/
|
||||||
label = 'item.page.abstract';
|
label = 'item.page.abstract';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the {@link MarkdownPipe} to render dc.description.abstract values
|
||||||
|
*/
|
||||||
|
enableMarkdown = true;
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,8 @@
|
|||||||
<div class="item-page-field">
|
<div class="item-page-field">
|
||||||
<ds-metadata-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-values>
|
<ds-metadata-values
|
||||||
|
[mdValues]="item?.allMetadata(fields)"
|
||||||
|
[separator]="separator"
|
||||||
|
[label]="label"
|
||||||
|
[enableMarkdown]="enableMarkdown"
|
||||||
|
></ds-metadata-values>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -8,9 +8,14 @@ import { MetadataValuesComponent } from '../../../field-components/metadata-valu
|
|||||||
import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models';
|
import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models';
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||||
import { createPaginatedList } from '../../../../shared/testing/utils.test';
|
import { createPaginatedList } from '../../../../shared/testing/utils.test';
|
||||||
|
import { environment } from '../../../../../environments/environment';
|
||||||
|
import { MarkdownPipe } from '../../../../shared/utils/markdown.pipe';
|
||||||
|
import { SharedModule } from '../../../../shared/shared.module';
|
||||||
|
import { APP_CONFIG } from '../../../../../config/app-config.interface';
|
||||||
|
|
||||||
let comp: ItemPageFieldComponent;
|
let comp: ItemPageFieldComponent;
|
||||||
let fixture: ComponentFixture<ItemPageFieldComponent>;
|
let fixture: ComponentFixture<ItemPageFieldComponent>;
|
||||||
|
let markdownSpy;
|
||||||
|
|
||||||
const mockValue = 'test value';
|
const mockValue = 'test value';
|
||||||
const mockField = 'dc.test';
|
const mockField = 'dc.test';
|
||||||
@@ -20,17 +25,24 @@ const mockFields = [mockField];
|
|||||||
describe('ItemPageFieldComponent', () => {
|
describe('ItemPageFieldComponent', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot({
|
imports: [
|
||||||
loader: {
|
TranslateModule.forRoot({
|
||||||
provide: TranslateLoader,
|
loader: {
|
||||||
useClass: TranslateLoaderMock
|
provide: TranslateLoader,
|
||||||
}
|
useClass: TranslateLoaderMock
|
||||||
})],
|
}
|
||||||
|
}),
|
||||||
|
SharedModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_CONFIG, useValue: Object.assign({}, environment) },
|
||||||
|
],
|
||||||
declarations: [ItemPageFieldComponent, MetadataValuesComponent],
|
declarations: [ItemPageFieldComponent, MetadataValuesComponent],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
}).overrideComponent(ItemPageFieldComponent, {
|
}).overrideComponent(ItemPageFieldComponent, {
|
||||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
markdownSpy = spyOn(MarkdownPipe.prototype, 'transform');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
@@ -45,6 +57,68 @@ describe('ItemPageFieldComponent', () => {
|
|||||||
it('should display display the correct metadata value', () => {
|
it('should display display the correct metadata value', () => {
|
||||||
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
|
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when markdown is disabled in the environment config', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.inject(APP_CONFIG).markdown.enabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and markdown is disabled in this component', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.enableMarkdown = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use the Markdown Pipe', () => {
|
||||||
|
expect(markdownSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and markdown is enabled in this component', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.enableMarkdown = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use the Markdown Pipe', () => {
|
||||||
|
expect(markdownSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when markdown is enabled in the environment config', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.inject(APP_CONFIG).markdown.enabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and markdown is disabled in this component', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.enableMarkdown = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use the Markdown Pipe', () => {
|
||||||
|
expect(markdownSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and markdown is enabled in this component', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.enableMarkdown = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use the Markdown Pipe', () => {
|
||||||
|
expect(markdownSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item {
|
export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item {
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
import { Component, Input } from '@angular/core';
|
||||||
|
|
||||||
import { Item } from '../../../../core/shared/item.model';
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +17,11 @@ export class ItemPageFieldComponent {
|
|||||||
*/
|
*/
|
||||||
@Input() item: Item;
|
@Input() item: Item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the {@link MarkdownPipe} should be used to render this metadata.
|
||||||
|
*/
|
||||||
|
enableMarkdown = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields (schema.element.qualifier) used to render their values.
|
* Fields (schema.element.qualifier) used to render their values.
|
||||||
*/
|
*/
|
||||||
|
@@ -8,15 +8,14 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
|||||||
import { AuthService } from '../../core/auth/auth.service';
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
|
|
||||||
import { UploaderOptions } from '../../shared/uploader/uploader-options.model';
|
import { UploaderOptions } from '../../shared/uploader/uploader-options.model';
|
||||||
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
|
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
|
||||||
import { NotificationType } from '../../shared/notifications/models/notification-type';
|
|
||||||
import { hasValue } from '../../shared/empty.util';
|
import { hasValue } from '../../shared/empty.util';
|
||||||
import { SearchResult } from '../../shared/search/models/search-result.model';
|
import { SearchResult } from '../../shared/search/models/search-result.model';
|
||||||
import { CollectionSelectorComponent } from '../collection-selector/collection-selector.component';
|
import { CollectionSelectorComponent } from '../collection-selector/collection-selector.component';
|
||||||
import { UploaderComponent } from '../../shared/uploader/uploader.component';
|
import { UploaderComponent } from '../../shared/uploader/uploader.component';
|
||||||
import { UploaderError } from '../../shared/uploader/uploader-error.model';
|
import { UploaderError } from '../../shared/uploader/uploader-error.model';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component represents the whole mydspace page header
|
* This component represents the whole mydspace page header
|
||||||
@@ -56,13 +55,15 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit {
|
|||||||
* @param {NotificationsService} notificationsService
|
* @param {NotificationsService} notificationsService
|
||||||
* @param {TranslateService} translate
|
* @param {TranslateService} translate
|
||||||
* @param {NgbModal} modalService
|
* @param {NgbModal} modalService
|
||||||
|
* @param {Router} router
|
||||||
*/
|
*/
|
||||||
constructor(private authService: AuthService,
|
constructor(private authService: AuthService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private halService: HALEndpointService,
|
private halService: HALEndpointService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
private translate: TranslateService,
|
private translate: TranslateService,
|
||||||
private modalService: NgbModal) {
|
private modalService: NgbModal,
|
||||||
|
private router: Router) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,16 +88,9 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit {
|
|||||||
this.uploadEnd.emit(workspaceitems);
|
this.uploadEnd.emit(workspaceitems);
|
||||||
|
|
||||||
if (workspaceitems.length === 1) {
|
if (workspaceitems.length === 1) {
|
||||||
const options = new NotificationOptions();
|
|
||||||
options.timeOut = 0;
|
|
||||||
const link = '/workspaceitems/' + workspaceitems[0].id + '/edit';
|
const link = '/workspaceitems/' + workspaceitems[0].id + '/edit';
|
||||||
this.notificationsService.notificationWithAnchor(
|
// To avoid confusion and ambiguity, redirect the user on the publication page.
|
||||||
NotificationType.Success,
|
this.router.navigateByUrl(link);
|
||||||
options,
|
|
||||||
link,
|
|
||||||
'mydspace.general.text-here',
|
|
||||||
'mydspace.upload.upload-successful',
|
|
||||||
'here');
|
|
||||||
} else if (workspaceitems.length > 1) {
|
} else if (workspaceitems.length > 1) {
|
||||||
this.notificationsService.success(null, this.translate.get('mydspace.upload.upload-multiple-successful', {qty: workspaceitems.length}));
|
this.notificationsService.success(null, this.translate.get('mydspace.upload.upload-multiple-successful', {qty: workspaceitems.length}));
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,7 @@ nav.navbar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mobile menu styling **/
|
/** Mobile menu styling **/
|
||||||
@media screen and (max-width: map-get($grid-breakpoints, md)) {
|
@media screen and (max-width: map-get($grid-breakpoints, md)-0.02) {
|
||||||
.navbar {
|
.navbar {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
background-color: var(--bs-white);
|
background-color: var(--bs-white);
|
||||||
@@ -26,7 +26,7 @@ nav.navbar {
|
|||||||
|
|
||||||
/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */
|
/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */
|
||||||
.navbar-expand-md.navbar-container {
|
.navbar-expand-md.navbar-container {
|
||||||
@media screen and (max-width: map-get($grid-breakpoints, md)) {
|
@media screen and (max-width: map-get($grid-breakpoints, md)-0.02) {
|
||||||
> .container {
|
> .container {
|
||||||
padding: 0 var(--bs-spacer);
|
padding: 0 var(--bs-spacer);
|
||||||
}
|
}
|
||||||
|
@@ -74,6 +74,19 @@ describe('ProfilePageSecurityFormComponent', () => {
|
|||||||
|
|
||||||
expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password');
|
expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('should emit the value on password change with current password for profile-page', fakeAsync(() => {
|
||||||
|
spyOn(component.passwordValue, 'emit');
|
||||||
|
spyOn(component.currentPasswordValue, 'emit');
|
||||||
|
component.FORM_PREFIX = 'profile.security.form.';
|
||||||
|
component.ngOnInit();
|
||||||
|
component.formGroup.patchValue({password: 'new-password'});
|
||||||
|
component.formGroup.patchValue({'current-password': 'current-password'});
|
||||||
|
tick(300);
|
||||||
|
|
||||||
|
expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password');
|
||||||
|
expect(component.currentPasswordValue.emit).toHaveBeenCalledWith('current-password');
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -27,6 +27,10 @@ export class ProfilePageSecurityFormComponent implements OnInit {
|
|||||||
* Emits the value of the password
|
* Emits the value of the password
|
||||||
*/
|
*/
|
||||||
@Output() passwordValue = new EventEmitter<string>();
|
@Output() passwordValue = new EventEmitter<string>();
|
||||||
|
/**
|
||||||
|
* Emits the value of the current-password
|
||||||
|
*/
|
||||||
|
@Output() currentPasswordValue = new EventEmitter<string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The form's input models
|
* The form's input models
|
||||||
@@ -70,6 +74,14 @@ export class ProfilePageSecurityFormComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
if (this.FORM_PREFIX === 'profile.security.form.') {
|
||||||
|
this.formModel.unshift(new DynamicInputModel({
|
||||||
|
id: 'current-password',
|
||||||
|
name: 'current-password',
|
||||||
|
inputType: 'password',
|
||||||
|
required: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
if (this.passwordCanBeEmpty) {
|
if (this.passwordCanBeEmpty) {
|
||||||
this.formGroup = this.formService.createFormGroup(this.formModel,
|
this.formGroup = this.formService.createFormGroup(this.formModel,
|
||||||
{ validators: [this.checkPasswordsEqual] });
|
{ validators: [this.checkPasswordsEqual] });
|
||||||
@@ -94,6 +106,9 @@ export class ProfilePageSecurityFormComponent implements OnInit {
|
|||||||
debounceTime(300),
|
debounceTime(300),
|
||||||
).subscribe((valueChange) => {
|
).subscribe((valueChange) => {
|
||||||
this.passwordValue.emit(valueChange.password);
|
this.passwordValue.emit(valueChange.password);
|
||||||
|
if (this.FORM_PREFIX === 'profile.security.form.') {
|
||||||
|
this.currentPasswordValue.emit(valueChange['current-password']);
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -24,6 +24,7 @@
|
|||||||
[FORM_PREFIX]="'profile.security.form.'"
|
[FORM_PREFIX]="'profile.security.form.'"
|
||||||
(isInvalid)="setInvalid($event)"
|
(isInvalid)="setInvalid($event)"
|
||||||
(passwordValue)="setPasswordValue($event)"
|
(passwordValue)="setPasswordValue($event)"
|
||||||
|
(currentPasswordValue)="setCurrentPasswordValue($event)"
|
||||||
></ds-profile-page-security-form>
|
></ds-profile-page-security-form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -180,7 +180,7 @@ describe('ProfilePageComponent', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
component.setPasswordValue('');
|
component.setPasswordValue('');
|
||||||
|
component.setCurrentPasswordValue('current-password');
|
||||||
result = component.updateSecurity();
|
result = component.updateSecurity();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,6 +199,7 @@ describe('ProfilePageComponent', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
component.setPasswordValue('test');
|
component.setPasswordValue('test');
|
||||||
component.setInvalid(true);
|
component.setInvalid(true);
|
||||||
|
component.setCurrentPasswordValue('current-password');
|
||||||
result = component.updateSecurity();
|
result = component.updateSecurity();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,8 +216,11 @@ describe('ProfilePageComponent', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
component.setPasswordValue('testest');
|
component.setPasswordValue('testest');
|
||||||
component.setInvalid(false);
|
component.setInvalid(false);
|
||||||
|
component.setCurrentPasswordValue('current-password');
|
||||||
|
|
||||||
operations = [{ op: 'add', path: '/password', value: 'testest' }];
|
operations = [
|
||||||
|
{ 'op': 'add', 'path': '/password', 'value': { 'new_password': 'testest', 'current_password': 'current-password' } }
|
||||||
|
];
|
||||||
result = component.updateSecurity();
|
result = component.updateSecurity();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,6 +232,28 @@ describe('ProfilePageComponent', () => {
|
|||||||
expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
|
expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when password is filled in, and is valid but return 403', () => {
|
||||||
|
let result;
|
||||||
|
let operations;
|
||||||
|
|
||||||
|
it('should return call epersonService.patch', (done) => {
|
||||||
|
epersonService.patch.and.returnValue(observableOf(Object.assign(new RestResponse(false, 403, 'Error'))));
|
||||||
|
component.setPasswordValue('testest');
|
||||||
|
component.setInvalid(false);
|
||||||
|
component.setCurrentPasswordValue('current-password');
|
||||||
|
operations = [
|
||||||
|
{ 'op': 'add', 'path': '/password', 'value': {'new_password': 'testest', 'current_password': 'current-password' }}
|
||||||
|
];
|
||||||
|
result = component.updateSecurity();
|
||||||
|
epersonService.patch(user, operations).subscribe((response) => {
|
||||||
|
expect(response.statusCode).toEqual(403);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
|
||||||
|
expect(result).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('canChangePassword$', () => {
|
describe('canChangePassword$', () => {
|
||||||
|
@@ -67,6 +67,10 @@ export class ProfilePageComponent implements OnInit {
|
|||||||
* The password filled in, in the security form
|
* The password filled in, in the security form
|
||||||
*/
|
*/
|
||||||
private password: string;
|
private password: string;
|
||||||
|
/**
|
||||||
|
* The current-password filled in, in the security form
|
||||||
|
*/
|
||||||
|
private currentPassword: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The authenticated user
|
* The authenticated user
|
||||||
@@ -138,15 +142,14 @@ export class ProfilePageComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
updateSecurity() {
|
updateSecurity() {
|
||||||
const passEntered = isNotEmpty(this.password);
|
const passEntered = isNotEmpty(this.password);
|
||||||
|
|
||||||
if (this.invalidSecurity) {
|
if (this.invalidSecurity) {
|
||||||
this.notificationsService.error(this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.general'));
|
this.notificationsService.error(this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.general'));
|
||||||
}
|
}
|
||||||
if (!this.invalidSecurity && passEntered) {
|
if (!this.invalidSecurity && passEntered) {
|
||||||
const operation = {op: 'add', path: '/password', value: this.password} as Operation;
|
const operations = [
|
||||||
this.epersonService.patch(this.currentUser, [operation]).pipe(
|
{ 'op': 'add', 'path': '/password', 'value': { 'new_password': this.password, 'current_password': this.currentPassword } }
|
||||||
getFirstCompletedRemoteData()
|
] as Operation[];
|
||||||
).subscribe((response: RemoteData<EPerson>) => {
|
this.epersonService.patch(this.currentUser, operations).pipe(getFirstCompletedRemoteData()).subscribe((response: RemoteData<EPerson>) => {
|
||||||
if (response.hasSucceeded) {
|
if (response.hasSucceeded) {
|
||||||
this.notificationsService.success(
|
this.notificationsService.success(
|
||||||
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.title'),
|
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.title'),
|
||||||
@@ -154,7 +157,8 @@ export class ProfilePageComponent implements OnInit {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(
|
this.notificationsService.error(
|
||||||
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.title'), response.errorMessage
|
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.title'),
|
||||||
|
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.change-failed')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -170,6 +174,14 @@ export class ProfilePageComponent implements OnInit {
|
|||||||
this.password = $event;
|
this.password = $event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current-password value based on the value emitted from the security form
|
||||||
|
* @param $event
|
||||||
|
*/
|
||||||
|
setCurrentPasswordValue($event: string) {
|
||||||
|
this.currentPassword = $event;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit of the security form that triggers the updateProfile method
|
* Submit of the security form that triggers the updateProfile method
|
||||||
*/
|
*/
|
||||||
|
@@ -41,6 +41,11 @@ export class CreateProfileComponent implements OnInit {
|
|||||||
userInfoForm: FormGroup;
|
userInfoForm: FormGroup;
|
||||||
activeLangs: LangConfig[];
|
activeLangs: LangConfig[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix for the notification messages of this security form
|
||||||
|
*/
|
||||||
|
NOTIFICATIONS_PREFIX = 'register-page.create-profile.submit.';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
private ePersonDataService: EPersonDataService,
|
private ePersonDataService: EPersonDataService,
|
||||||
@@ -161,13 +166,12 @@ export class CreateProfileComponent implements OnInit {
|
|||||||
getFirstCompletedRemoteData(),
|
getFirstCompletedRemoteData(),
|
||||||
).subscribe((rd: RemoteData<EPerson>) => {
|
).subscribe((rd: RemoteData<EPerson>) => {
|
||||||
if (rd.hasSucceeded) {
|
if (rd.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get('register-page.create-profile.submit.success.head'),
|
this.notificationsService.success(this.translateService.get(this.NOTIFICATIONS_PREFIX + 'success.head'),
|
||||||
this.translateService.get('register-page.create-profile.submit.success.content'));
|
this.translateService.get(this.NOTIFICATIONS_PREFIX + 'success.content'));
|
||||||
this.store.dispatch(new AuthenticateAction(this.email, this.password));
|
this.store.dispatch(new AuthenticateAction(this.email, this.password));
|
||||||
this.router.navigate(['/home']);
|
this.router.navigate(['/home']);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get('register-page.create-profile.submit.error.head'),
|
this.notificationsService.error(this.translateService.get(this.NOTIFICATIONS_PREFIX + 'error.head'), rd.errorMessage);
|
||||||
this.translateService.get('register-page.create-profile.submit.error.content'));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,6 @@ import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
|
|||||||
import { NativeWindowRef, NativeWindowService } from '../core/services/window.service';
|
import { NativeWindowRef, NativeWindowService } from '../core/services/window.service';
|
||||||
import { MetadataService } from '../core/metadata/metadata.service';
|
import { MetadataService } from '../core/metadata/metadata.service';
|
||||||
import { MetadataServiceMock } from '../shared/mocks/metadata-service.mock';
|
import { MetadataServiceMock } from '../shared/mocks/metadata-service.mock';
|
||||||
import { Angulartics2GoogleAnalytics } from 'angulartics2';
|
|
||||||
import { AngularticsProviderMock } from '../shared/mocks/angulartics-provider.service.mock';
|
import { AngularticsProviderMock } from '../shared/mocks/angulartics-provider.service.mock';
|
||||||
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
|
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
|
||||||
import { AuthService } from '../core/auth/auth.service';
|
import { AuthService } from '../core/auth/auth.service';
|
||||||
@@ -50,7 +49,6 @@ describe('RootComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: NativeWindowService, useValue: new NativeWindowRef() },
|
{ provide: NativeWindowService, useValue: new NativeWindowRef() },
|
||||||
{ provide: MetadataService, useValue: new MetadataServiceMock() },
|
{ provide: MetadataService, useValue: new MetadataServiceMock() },
|
||||||
{ provide: Angulartics2GoogleAnalytics, useValue: new AngularticsProviderMock() },
|
|
||||||
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
|
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
|
||||||
{ provide: AuthService, useValue: new AuthServiceMock() },
|
{ provide: AuthService, useValue: new AuthServiceMock() },
|
||||||
{ provide: Router, useValue: new RouterMock() },
|
{ provide: Router, useValue: new RouterMock() },
|
||||||
|
@@ -5,7 +5,6 @@ import { Router } from '@angular/router';
|
|||||||
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
|
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { Angulartics2GoogleAnalytics } from 'angulartics2';
|
|
||||||
|
|
||||||
import { MetadataService } from '../core/metadata/metadata.service';
|
import { MetadataService } from '../core/metadata/metadata.service';
|
||||||
import { HostWindowState } from '../shared/search/host-window.reducer';
|
import { HostWindowState } from '../shared/search/host-window.reducer';
|
||||||
@@ -51,7 +50,6 @@ export class RootComponent implements OnInit {
|
|||||||
private translate: TranslateService,
|
private translate: TranslateService,
|
||||||
private store: Store<HostWindowState>,
|
private store: Store<HostWindowState>,
|
||||||
private metadata: MetadataService,
|
private metadata: MetadataService,
|
||||||
private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics,
|
|
||||||
private angulartics2DSpace: Angulartics2DSpace,
|
private angulartics2DSpace: Angulartics2DSpace,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
24
src/app/search-navbar/themed-search-navbar.component.ts
Normal file
24
src/app/search-navbar/themed-search-navbar.component.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ThemedComponent } from '../shared/theme-support/themed.component';
|
||||||
|
import { SearchNavbarComponent } from './search-navbar.component';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-search-navbar',
|
||||||
|
styleUrls: [],
|
||||||
|
templateUrl: '../shared/theme-support/themed.component.html',
|
||||||
|
})
|
||||||
|
export class ThemedSearchNavbarComponent extends ThemedComponent<SearchNavbarComponent> {
|
||||||
|
|
||||||
|
protected getComponentName(): string {
|
||||||
|
return 'SearchNavbarComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importThemedComponent(themeName: string): Promise<any> {
|
||||||
|
return import(`../../themes/${themeName}/app/search-navbar/search-navbar.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importUnthemedComponent(): Promise<any> {
|
||||||
|
return import(`./search-navbar.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -198,11 +198,13 @@ describe('BrowseByComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use the base component to render browse entries', () => {
|
it('should use the base component to render browse entries', () => {
|
||||||
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
|
waitForAsync(() => {
|
||||||
expect(componentLoaders.length).toEqual(browseEntries.length);
|
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
|
||||||
componentLoaders.forEach((componentLoader) => {
|
expect(componentLoaders.length).toEqual(browseEntries.length);
|
||||||
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
|
componentLoaders.forEach((componentLoader) => {
|
||||||
expect(browseEntry.componentInstance).toBeInstanceOf(BrowseEntryListElementComponent);
|
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
|
||||||
|
expect(browseEntry.componentInstance).toBeInstanceOf(BrowseEntryListElementComponent);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -215,11 +217,13 @@ describe('BrowseByComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use the themed component to render browse entries', () => {
|
it('should use the themed component to render browse entries', () => {
|
||||||
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
|
waitForAsync(() => {
|
||||||
expect(componentLoaders.length).toEqual(browseEntries.length);
|
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
|
||||||
componentLoaders.forEach((componentLoader) => {
|
expect(componentLoaders.length).toEqual(browseEntries.length);
|
||||||
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
|
componentLoaders.forEach((componentLoader) => {
|
||||||
expect(browseEntry.componentInstance).toBeInstanceOf(MockThemedBrowseEntryListElementComponent);
|
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
|
||||||
|
expect(browseEntry.componentInstance).toBeInstanceOf(MockThemedBrowseEntryListElementComponent);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -0,0 +1,36 @@
|
|||||||
|
import {Component, Input} from '@angular/core';
|
||||||
|
import { ThemedComponent } from '../../theme-support/themed.component';
|
||||||
|
import { ComcolPageHandleComponent } from './comcol-page-handle.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themed wrapper for BreadcrumbsComponent
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-comcol-page-handle',
|
||||||
|
styleUrls: [],
|
||||||
|
templateUrl: '../../theme-support/themed.component.html',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export class ThemedComcolPageHandleComponent extends ThemedComponent<ComcolPageHandleComponent> {
|
||||||
|
|
||||||
|
// Optional title
|
||||||
|
@Input() title: string;
|
||||||
|
|
||||||
|
// The value of "handle"
|
||||||
|
@Input() content: string;
|
||||||
|
|
||||||
|
inAndOutputNames: (keyof ComcolPageHandleComponent & keyof this)[] = ['title', 'content'];
|
||||||
|
|
||||||
|
protected getComponentName(): string {
|
||||||
|
return 'ComcolPageHandleComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importThemedComponent(themeName: string): Promise<any> {
|
||||||
|
return import(`../../../../themes/${themeName}/app/shared/comcol/comcol-page-handle/comcol-page-handle.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importUnthemedComponent(): Promise<any> {
|
||||||
|
return import(`./comcol-page-handle.component`);
|
||||||
|
}
|
||||||
|
}
|
@@ -2,6 +2,8 @@ import { NgModule } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ComcolPageContentComponent } from './comcol-page-content/comcol-page-content.component';
|
import { ComcolPageContentComponent } from './comcol-page-content/comcol-page-content.component';
|
||||||
import { ComcolPageHandleComponent } from './comcol-page-handle/comcol-page-handle.component';
|
import { ComcolPageHandleComponent } from './comcol-page-handle/comcol-page-handle.component';
|
||||||
|
import { ThemedComcolPageHandleComponent} from './comcol-page-handle/themed-comcol-page-handle.component';
|
||||||
|
|
||||||
import { ComcolPageHeaderComponent } from './comcol-page-header/comcol-page-header.component';
|
import { ComcolPageHeaderComponent } from './comcol-page-header/comcol-page-header.component';
|
||||||
import { ComcolPageLogoComponent } from './comcol-page-logo/comcol-page-logo.component';
|
import { ComcolPageLogoComponent } from './comcol-page-logo/comcol-page-logo.component';
|
||||||
import { ComColFormComponent } from './comcol-forms/comcol-form/comcol-form.component';
|
import { ComColFormComponent } from './comcol-forms/comcol-form/comcol-form.component';
|
||||||
@@ -26,6 +28,9 @@ const COMPONENTS = [
|
|||||||
ComcolPageBrowseByComponent,
|
ComcolPageBrowseByComponent,
|
||||||
ThemedComcolPageBrowseByComponent,
|
ThemedComcolPageBrowseByComponent,
|
||||||
ComcolRoleComponent,
|
ComcolRoleComponent,
|
||||||
|
|
||||||
|
ThemedComcolPageHandleComponent
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@@ -10,10 +10,12 @@ import { AuthService } from '../../core/auth/auth.service';
|
|||||||
import { CookieService } from '../../core/services/cookie.service';
|
import { CookieService } from '../../core/services/cookie.service';
|
||||||
import { getTestScheduler } from 'jasmine-marbles';
|
import { getTestScheduler } from 'jasmine-marbles';
|
||||||
import { MetadataValue } from '../../core/shared/metadata.models';
|
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||||
import {clone, cloneDeep} from 'lodash';
|
import { clone, cloneDeep } from 'lodash';
|
||||||
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
|
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
|
||||||
import {createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$} from '../remote-data.utils';
|
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
|
||||||
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
|
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
|
||||||
|
import { ANONYMOUS_STORAGE_NAME_KLARO } from './klaro-configuration';
|
||||||
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
|
|
||||||
describe('BrowserKlaroService', () => {
|
describe('BrowserKlaroService', () => {
|
||||||
const trackingIdProp = 'google.analytics.key';
|
const trackingIdProp = 'google.analytics.key';
|
||||||
@@ -29,7 +31,7 @@ describe('BrowserKlaroService', () => {
|
|||||||
let configurationDataService: ConfigurationDataService;
|
let configurationDataService: ConfigurationDataService;
|
||||||
const createConfigSuccessSpy = (...values: string[]) => jasmine.createSpyObj('configurationDataService', {
|
const createConfigSuccessSpy = (...values: string[]) => jasmine.createSpyObj('configurationDataService', {
|
||||||
findByPropertyName: createSuccessfulRemoteDataObject$({
|
findByPropertyName: createSuccessfulRemoteDataObject$({
|
||||||
... new ConfigurationProperty(),
|
...new ConfigurationProperty(),
|
||||||
name: trackingIdProp,
|
name: trackingIdProp,
|
||||||
values: values,
|
values: values,
|
||||||
}),
|
}),
|
||||||
@@ -42,7 +44,9 @@ describe('BrowserKlaroService', () => {
|
|||||||
let findByPropertyName;
|
let findByPropertyName;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
user = new EPerson();
|
user = Object.assign(new EPerson(), {
|
||||||
|
uuid: 'test-user'
|
||||||
|
});
|
||||||
|
|
||||||
translateService = getMockTranslateService();
|
translateService = getMockTranslateService();
|
||||||
ePersonService = jasmine.createSpyObj('ePersonService', {
|
ePersonService = jasmine.createSpyObj('ePersonService', {
|
||||||
@@ -104,7 +108,7 @@ describe('BrowserKlaroService', () => {
|
|||||||
services: [{
|
services: [{
|
||||||
name: appName,
|
name: appName,
|
||||||
purposes: [purpose]
|
purposes: [purpose]
|
||||||
},{
|
}, {
|
||||||
name: googleAnalytics,
|
name: googleAnalytics,
|
||||||
purposes: [purpose]
|
purposes: [purpose]
|
||||||
}],
|
}],
|
||||||
@@ -219,6 +223,40 @@ describe('BrowserKlaroService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getSavedPreferences', () => {
|
||||||
|
let scheduler: TestScheduler;
|
||||||
|
beforeEach(() => {
|
||||||
|
scheduler = getTestScheduler();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when no user is autheticated', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(service as any, 'getUser$').and.returnValue(observableOf(undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the cookie consents object', () => {
|
||||||
|
scheduler.schedule(() => service.getSavedPreferences().subscribe());
|
||||||
|
scheduler.flush();
|
||||||
|
|
||||||
|
expect(cookieService.get).toHaveBeenCalledWith(ANONYMOUS_STORAGE_NAME_KLARO);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when user is autheticated', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(service as any, 'getUser$').and.returnValue(observableOf(user));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the cookie consents object', () => {
|
||||||
|
scheduler.schedule(() => service.getSavedPreferences().subscribe());
|
||||||
|
scheduler.flush();
|
||||||
|
|
||||||
|
expect(cookieService.get).toHaveBeenCalledWith('klaro-' + user.uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('setSettingsForUser when there are changes', () => {
|
describe('setSettingsForUser when there are changes', () => {
|
||||||
const cookieConsent = { test: 'testt' };
|
const cookieConsent = { test: 'testt' };
|
||||||
const cookieConsentString = '{test: \'testt\'}';
|
const cookieConsentString = '{test: \'testt\'}';
|
||||||
@@ -271,40 +309,40 @@ describe('BrowserKlaroService', () => {
|
|||||||
});
|
});
|
||||||
it('should not filter googleAnalytics when servicesToHide are empty', () => {
|
it('should not filter googleAnalytics when servicesToHide are empty', () => {
|
||||||
const filteredConfig = (service as any).filterConfigServices([]);
|
const filteredConfig = (service as any).filterConfigServices([]);
|
||||||
expect(filteredConfig).toContain(jasmine.objectContaining({name: googleAnalytics}));
|
expect(filteredConfig).toContain(jasmine.objectContaining({ name: googleAnalytics }));
|
||||||
});
|
});
|
||||||
it('should filter services using names passed as servicesToHide', () => {
|
it('should filter services using names passed as servicesToHide', () => {
|
||||||
const filteredConfig = (service as any).filterConfigServices([googleAnalytics]);
|
const filteredConfig = (service as any).filterConfigServices([googleAnalytics]);
|
||||||
expect(filteredConfig).not.toContain(jasmine.objectContaining({name: googleAnalytics}));
|
expect(filteredConfig).not.toContain(jasmine.objectContaining({ name: googleAnalytics }));
|
||||||
});
|
});
|
||||||
it('should have been initialized with googleAnalytics', () => {
|
it('should have been initialized with googleAnalytics', () => {
|
||||||
service.initialize();
|
service.initialize();
|
||||||
expect(service.klaroConfig.services).toContain(jasmine.objectContaining({name: googleAnalytics}));
|
expect(service.klaroConfig.services).toContain(jasmine.objectContaining({ name: googleAnalytics }));
|
||||||
});
|
});
|
||||||
it('should filter googleAnalytics when empty configuration is retrieved', () => {
|
it('should filter googleAnalytics when empty configuration is retrieved', () => {
|
||||||
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
|
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
|
||||||
createSuccessfulRemoteDataObject$({
|
createSuccessfulRemoteDataObject$({
|
||||||
... new ConfigurationProperty(),
|
...new ConfigurationProperty(),
|
||||||
name: googleAnalytics,
|
name: googleAnalytics,
|
||||||
values: [],
|
values: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
service.initialize();
|
service.initialize();
|
||||||
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics}));
|
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics }));
|
||||||
});
|
});
|
||||||
it('should filter googleAnalytics when an error occurs', () => {
|
it('should filter googleAnalytics when an error occurs', () => {
|
||||||
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
|
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
|
||||||
createFailedRemoteDataObject$('Erro while loading GA')
|
createFailedRemoteDataObject$('Erro while loading GA')
|
||||||
);
|
);
|
||||||
service.initialize();
|
service.initialize();
|
||||||
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics}));
|
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics }));
|
||||||
});
|
});
|
||||||
it('should filter googleAnalytics when an invalid payload is retrieved', () => {
|
it('should filter googleAnalytics when an invalid payload is retrieved', () => {
|
||||||
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
|
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
|
||||||
createSuccessfulRemoteDataObject$(null)
|
createSuccessfulRemoteDataObject$(null)
|
||||||
);
|
);
|
||||||
service.initialize();
|
service.initialize();
|
||||||
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics}));
|
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -13,7 +13,7 @@ import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
|||||||
import { cloneDeep, debounce } from 'lodash';
|
import { cloneDeep, debounce } from 'lodash';
|
||||||
import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration';
|
import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration';
|
||||||
import { Operation } from 'fast-json-patch';
|
import { Operation } from 'fast-json-patch';
|
||||||
import { getFirstCompletedRemoteData} from '../../core/shared/operators';
|
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||||
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
|
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,6 +121,23 @@ export class BrowserKlaroService extends KlaroService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return saved preferences stored in the klaro cookie
|
||||||
|
*/
|
||||||
|
getSavedPreferences(): Observable<any> {
|
||||||
|
return this.getUser$().pipe(
|
||||||
|
map((user: EPerson) => {
|
||||||
|
let storageName;
|
||||||
|
if (isEmpty(user)) {
|
||||||
|
storageName = ANONYMOUS_STORAGE_NAME_KLARO;
|
||||||
|
} else {
|
||||||
|
storageName = this.getStorageName(user.uuid);
|
||||||
|
}
|
||||||
|
return this.cookieService.get(storageName);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize configuration for the logged in user
|
* Initialize configuration for the logged in user
|
||||||
* @param user The authenticated user
|
* @param user The authenticated user
|
||||||
|
@@ -12,6 +12,8 @@ export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser';
|
|||||||
*/
|
*/
|
||||||
export const ANONYMOUS_STORAGE_NAME_KLARO = 'klaro-anonymous';
|
export const ANONYMOUS_STORAGE_NAME_KLARO = 'klaro-anonymous';
|
||||||
|
|
||||||
|
export const GOOGLE_ANALYTICS_KLARO_KEY = 'google-analytics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Klaro configuration
|
* Klaro configuration
|
||||||
* For more information see https://kiprotect.com/docs/klaro/annotated-config
|
* For more information see https://kiprotect.com/docs/klaro/annotated-config
|
||||||
@@ -113,7 +115,7 @@ export const klaroConfiguration: any = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'google-analytics',
|
name: GOOGLE_ANALYTICS_KLARO_KEY,
|
||||||
purposes: ['statistical'],
|
purposes: ['statistical'],
|
||||||
required: false,
|
required: false,
|
||||||
cookies: [
|
cookies: [
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract class representing a service for handling Klaro consent preferences and UI
|
* Abstract class representing a service for handling Klaro consent preferences and UI
|
||||||
*/
|
*/
|
||||||
@@ -11,7 +13,12 @@ export abstract class KlaroService {
|
|||||||
abstract initialize();
|
abstract initialize();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a the dialog with the current consent preferences
|
* Shows a dialog with the current consent preferences
|
||||||
*/
|
*/
|
||||||
abstract showSettings();
|
abstract showSettings();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return saved preferences stored in the klaro cookie
|
||||||
|
*/
|
||||||
|
abstract getSavedPreferences(): Observable<any>;
|
||||||
}
|
}
|
||||||
|
@@ -13,6 +13,8 @@ export function getMockObjectCacheService(): ObjectCacheService {
|
|||||||
'hasByUUID',
|
'hasByUUID',
|
||||||
'hasByHref',
|
'hasByHref',
|
||||||
'getRequestUUIDBySelfLink',
|
'getRequestUUIDBySelfLink',
|
||||||
|
'addDependency',
|
||||||
|
'removeDependents',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
<ds-object-list [ngClass]="placeholderFontClass" [config]="config"
|
<ds-themed-object-list [ngClass]="placeholderFontClass"
|
||||||
|
[config]="config"
|
||||||
[sortConfig]="sortConfig"
|
[sortConfig]="sortConfig"
|
||||||
[objects]="objects"
|
[objects]="objects"
|
||||||
[hasBorder]="hasBorder"
|
[hasBorder]="hasBorder"
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
(prev)="goPrev()"
|
(prev)="goPrev()"
|
||||||
(next)="goNext()"
|
(next)="goNext()"
|
||||||
*ngIf="(currentMode$ | async) === viewModeEnum.ListElement">
|
*ngIf="(currentMode$ | async) === viewModeEnum.ListElement">
|
||||||
</ds-object-list>
|
</ds-themed-object-list>
|
||||||
|
|
||||||
<ds-object-grid [config]="config"
|
<ds-object-grid [config]="config"
|
||||||
[sortConfig]="sortConfig"
|
[sortConfig]="sortConfig"
|
||||||
|
209
src/app/shared/object-list/themed-object-list.component.ts
Normal file
209
src/app/shared/object-list/themed-object-list.component.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||||
|
import { ObjectListComponent } from './object-list.component';
|
||||||
|
import { ThemedComponent } from '../theme-support/themed.component';
|
||||||
|
import {ViewMode} from '../../core/shared/view-mode.model';
|
||||||
|
import {PaginationComponentOptions} from '../pagination/pagination-component-options.model';
|
||||||
|
import {SortDirection, SortOptions} from '../../core/cache/models/sort-options.model';
|
||||||
|
import {CollectionElementLinkType} from '../object-collection/collection-element-link.type';
|
||||||
|
import {Context} from '../../core/shared/context.model';
|
||||||
|
import {RemoteData} from '../../core/data/remote-data';
|
||||||
|
import {PaginatedList} from '../../core/data/paginated-list.model';
|
||||||
|
import {ListableObject} from '../object-collection/shared/listable-object.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themed wrapper for ObjectListComponent
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-object-list',
|
||||||
|
styleUrls: [],
|
||||||
|
templateUrl: '../theme-support/themed.component.html',
|
||||||
|
})
|
||||||
|
export class ThemedObjectListComponent extends ThemedComponent<ObjectListComponent> {
|
||||||
|
/**
|
||||||
|
* The view mode of the this component
|
||||||
|
*/
|
||||||
|
viewMode = ViewMode.ListElement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current pagination configuration
|
||||||
|
*/
|
||||||
|
@Input() config: PaginationComponentOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current sort configuration
|
||||||
|
*/
|
||||||
|
@Input() sortConfig: SortOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the list elements have a border
|
||||||
|
*/
|
||||||
|
@Input() hasBorder = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whether or not the gear is hidden
|
||||||
|
*/
|
||||||
|
@Input() hideGear = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the pager is visible when there is only a single page of results
|
||||||
|
*/
|
||||||
|
@Input() hidePagerWhenSinglePage = true;
|
||||||
|
@Input() selectable = false;
|
||||||
|
@Input() selectionConfig: { repeatable: boolean, listId: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link type of the listable elements
|
||||||
|
*/
|
||||||
|
@Input() linkType: CollectionElementLinkType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The context of the listable elements
|
||||||
|
*/
|
||||||
|
@Input() context: Context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option for hiding the pagination detail
|
||||||
|
*/
|
||||||
|
@Input() hidePaginationDetail = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not to add an import button to the object
|
||||||
|
*/
|
||||||
|
@Input() importable = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config used for the import button
|
||||||
|
*/
|
||||||
|
@Input() importConfig: { importLabel: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination
|
||||||
|
*/
|
||||||
|
@Input() showPaginator = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit when one of the listed object has changed.
|
||||||
|
*/
|
||||||
|
@Output() contentChange = new EventEmitter<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If showPaginator is set to true, emit when the previous button is clicked
|
||||||
|
*/
|
||||||
|
@Output() prev = new EventEmitter<boolean>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If showPaginator is set to true, emit when the next button is clicked
|
||||||
|
*/
|
||||||
|
@Output() next = new EventEmitter<boolean>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current listable objects
|
||||||
|
*/
|
||||||
|
private _objects: RemoteData<PaginatedList<ListableObject>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the objects
|
||||||
|
* @param objects The new objects
|
||||||
|
*/
|
||||||
|
@Input() set objects(objects: RemoteData<PaginatedList<ListableObject>>) {
|
||||||
|
this._objects = objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter to return the current objects
|
||||||
|
*/
|
||||||
|
get objects() {
|
||||||
|
return this._objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event fired when the page is changed.
|
||||||
|
* Event's payload equals to the newly selected page.
|
||||||
|
*/
|
||||||
|
@Output() change: EventEmitter<{
|
||||||
|
pagination: PaginationComponentOptions,
|
||||||
|
sort: SortOptions
|
||||||
|
}> = new EventEmitter<{
|
||||||
|
pagination: PaginationComponentOptions,
|
||||||
|
sort: SortOptions
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event fired when the page is changed.
|
||||||
|
* Event's payload equals to the newly selected page.
|
||||||
|
*/
|
||||||
|
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event fired when the page wsize is changed.
|
||||||
|
* Event's payload equals to the newly selected page size.
|
||||||
|
*/
|
||||||
|
@Output() pageSizeChange: EventEmitter<number> = new EventEmitter<number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event fired when the sort direction is changed.
|
||||||
|
* Event's payload equals to the newly selected sort direction.
|
||||||
|
*/
|
||||||
|
@Output() sortDirectionChange: EventEmitter<SortDirection> = new EventEmitter<SortDirection>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event fired when on of the pagination parameters changes
|
||||||
|
*/
|
||||||
|
@Output() paginationChange: EventEmitter<any> = new EventEmitter<any>();
|
||||||
|
|
||||||
|
@Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
|
||||||
|
|
||||||
|
@Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an import event to the parent component
|
||||||
|
*/
|
||||||
|
@Output() importObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event fired when the sort field is changed.
|
||||||
|
* Event's payload equals to the newly selected sort field.
|
||||||
|
*/
|
||||||
|
@Output() sortFieldChange: EventEmitter<string> = new EventEmitter<string>();
|
||||||
|
|
||||||
|
inAndOutputNames: (keyof ObjectListComponent & keyof this)[] = [
|
||||||
|
'config',
|
||||||
|
'sortConfig',
|
||||||
|
'hasBorder',
|
||||||
|
'hideGear',
|
||||||
|
'hidePagerWhenSinglePage',
|
||||||
|
'selectable',
|
||||||
|
'selectionConfig',
|
||||||
|
'linkType',
|
||||||
|
'context',
|
||||||
|
'hidePaginationDetail',
|
||||||
|
'importable',
|
||||||
|
'importConfig',
|
||||||
|
'showPaginator',
|
||||||
|
'contentChange',
|
||||||
|
'prev',
|
||||||
|
'next',
|
||||||
|
'objects',
|
||||||
|
'change',
|
||||||
|
'pageChange',
|
||||||
|
'pageSizeChange',
|
||||||
|
'sortDirectionChange',
|
||||||
|
'paginationChange',
|
||||||
|
'deselectObject',
|
||||||
|
'selectObject',
|
||||||
|
'importObject',
|
||||||
|
'sortFieldChange',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected getComponentName(): string {
|
||||||
|
return 'ObjectListComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importThemedComponent(themeName: string): Promise<any> {
|
||||||
|
return import(`../../../themes/${themeName}/app/shared/object-list/object-list.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importUnthemedComponent(): Promise<any> {
|
||||||
|
return import('./object-list.component');
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,33 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { ThemedComponent } from '../../theme-support/themed.component';
|
||||||
|
import { SearchSettingsComponent } from './search-settings.component';
|
||||||
|
import { SortOptions } from '../../../core/cache/models/sort-options.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themed wrapper for SearchSettingsComponent
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-search-settings',
|
||||||
|
styleUrls: [],
|
||||||
|
templateUrl: '../../theme-support/themed.component.html',
|
||||||
|
})
|
||||||
|
export class ThemedSearchSettingsComponent extends ThemedComponent<SearchSettingsComponent> {
|
||||||
|
@Input() currentSortOption: SortOptions;
|
||||||
|
@Input() sortOptionsList: SortOptions[];
|
||||||
|
|
||||||
|
|
||||||
|
protected inAndOutputNames: (keyof SearchSettingsComponent & keyof this)[] = [
|
||||||
|
'currentSortOption', 'sortOptionsList'];
|
||||||
|
|
||||||
|
protected getComponentName(): string {
|
||||||
|
return 'SearchSettingsComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importThemedComponent(themeName: string): Promise<any> {
|
||||||
|
return import(`../../../../themes/${themeName}/app/shared/search/search-settings/search-settings.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importUnthemedComponent(): Promise<any> {
|
||||||
|
return import('./search-settings.component');
|
||||||
|
}
|
||||||
|
}
|
@@ -21,7 +21,8 @@
|
|||||||
[currentConfiguration]="configuration"
|
[currentConfiguration]="configuration"
|
||||||
[refreshFilters]="refreshFilters"
|
[refreshFilters]="refreshFilters"
|
||||||
[inPlaceSearch]="inPlaceSearch"></ds-search-filters>
|
[inPlaceSearch]="inPlaceSearch"></ds-search-filters>
|
||||||
<ds-search-settings [currentSortOption]="currentSortOption" [sortOptionsList]="sortOptionsList"></ds-search-settings>
|
<ds-themed-search-settings [currentSortOption]="currentSortOption"
|
||||||
|
[sortOptionsList]="sortOptionsList"></ds-themed-search-settings>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -30,6 +30,7 @@ import { SearchResultsComponent } from './search-results/search-results.componen
|
|||||||
import { SearchComponent } from './search.component';
|
import { SearchComponent } from './search.component';
|
||||||
import { ThemedSearchComponent } from './themed-search.component';
|
import { ThemedSearchComponent } from './themed-search.component';
|
||||||
import { ThemedSearchResultsComponent } from './search-results/themed-search-results.component';
|
import { ThemedSearchResultsComponent } from './search-results/themed-search-results.component';
|
||||||
|
import { ThemedSearchSettingsComponent } from './search-settings/themed-search-settings.component';
|
||||||
|
|
||||||
const COMPONENTS = [
|
const COMPONENTS = [
|
||||||
SearchComponent,
|
SearchComponent,
|
||||||
@@ -55,6 +56,7 @@ const COMPONENTS = [
|
|||||||
ConfigurationSearchPageComponent,
|
ConfigurationSearchPageComponent,
|
||||||
ThemedConfigurationSearchPageComponent,
|
ThemedConfigurationSearchPageComponent,
|
||||||
ThemedSearchResultsComponent,
|
ThemedSearchResultsComponent,
|
||||||
|
ThemedSearchSettingsComponent,
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENTRY_COMPONENTS = [
|
const ENTRY_COMPONENTS = [
|
||||||
|
@@ -39,6 +39,7 @@ import {
|
|||||||
SearchResultListElementComponent
|
SearchResultListElementComponent
|
||||||
} from './object-list/search-result-list-element/search-result-list-element.component';
|
} from './object-list/search-result-list-element/search-result-list-element.component';
|
||||||
import { ObjectListComponent } from './object-list/object-list.component';
|
import { ObjectListComponent } from './object-list/object-list.component';
|
||||||
|
import { ThemedObjectListComponent } from './object-list/themed-object-list.component';
|
||||||
import {
|
import {
|
||||||
CollectionGridElementComponent
|
CollectionGridElementComponent
|
||||||
} from './object-grid/collection-grid-element/collection-grid-element.component';
|
} from './object-grid/collection-grid-element/collection-grid-element.component';
|
||||||
@@ -288,6 +289,7 @@ import { LinkMenuItemComponent } from './menu/menu-item/link-menu-item.component
|
|||||||
import { OnClickMenuItemComponent } from './menu/menu-item/onclick-menu-item.component';
|
import { OnClickMenuItemComponent } from './menu/menu-item/onclick-menu-item.component';
|
||||||
import { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component';
|
import { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component';
|
||||||
import { SearchNavbarComponent } from '../search-navbar/search-navbar.component';
|
import { SearchNavbarComponent } from '../search-navbar/search-navbar.component';
|
||||||
|
import { ThemedSearchNavbarComponent } from '../search-navbar/themed-search-navbar.component';
|
||||||
import {
|
import {
|
||||||
ItemVersionsSummaryModalComponent
|
ItemVersionsSummaryModalComponent
|
||||||
} from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component';
|
} from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component';
|
||||||
@@ -312,6 +314,7 @@ import { SearchExportCsvComponent } from './search/search-export-csv/search-expo
|
|||||||
import {
|
import {
|
||||||
ItemPageTitleFieldComponent
|
ItemPageTitleFieldComponent
|
||||||
} from '../item-page/simple/field-components/specific-field/title/item-page-title-field.component';
|
} from '../item-page/simple/field-components/specific-field/title/item-page-title-field.component';
|
||||||
|
import { MarkdownPipe } from './utils/markdown.pipe';
|
||||||
import {
|
import {
|
||||||
DsoEditMenuSectionComponent
|
DsoEditMenuSectionComponent
|
||||||
} from './dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component';
|
} from './dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component';
|
||||||
@@ -362,6 +365,7 @@ const PIPES = [
|
|||||||
ConsolePipe,
|
ConsolePipe,
|
||||||
ObjNgFor,
|
ObjNgFor,
|
||||||
BrowserOnlyPipe,
|
BrowserOnlyPipe,
|
||||||
|
MarkdownPipe,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COMPONENTS = [
|
const COMPONENTS = [
|
||||||
@@ -381,6 +385,7 @@ const COMPONENTS = [
|
|||||||
LogOutComponent,
|
LogOutComponent,
|
||||||
NumberPickerComponent,
|
NumberPickerComponent,
|
||||||
ObjectListComponent,
|
ObjectListComponent,
|
||||||
|
ThemedObjectListComponent,
|
||||||
ObjectDetailComponent,
|
ObjectDetailComponent,
|
||||||
ObjectGridComponent,
|
ObjectGridComponent,
|
||||||
AbstractListableElementComponent,
|
AbstractListableElementComponent,
|
||||||
@@ -501,6 +506,7 @@ const COMPONENTS = [
|
|||||||
SearchNavbarComponent,
|
SearchNavbarComponent,
|
||||||
ScopeSelectorModalComponent,
|
ScopeSelectorModalComponent,
|
||||||
ItemPageTitleFieldComponent,
|
ItemPageTitleFieldComponent,
|
||||||
|
ThemedSearchNavbarComponent,
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENTRY_COMPONENTS = [
|
const ENTRY_COMPONENTS = [
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<div class="clamp-{{background}}-{{lines}} min-{{minLines}} {{type}} {{fixedHeight ? 'fixedHeight' : ''}}">
|
<div class="clamp-{{background}}-{{lines}} min-{{minLines}} {{type}} {{fixedHeight ? 'fixedHeight' : ''}}">
|
||||||
<div #content class="content dont-break-out">
|
<div #content class="content dont-break-out preserve-line-breaks">
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-link p-0 expandButton" dsDragClick (actualClick)="toggle()">
|
<button class="btn btn-link p-0 expandButton" dsDragClick (actualClick)="toggle()">
|
||||||
|
64
src/app/shared/utils/markdown.pipe.spec.ts
Normal file
64
src/app/shared/utils/markdown.pipe.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { MarkdownPipe } from './markdown.pipe';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { APP_CONFIG } from '../../../config/app-config.interface';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
describe('Markdown Pipe', () => {
|
||||||
|
|
||||||
|
let markdownPipe: MarkdownPipe;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
MarkdownPipe,
|
||||||
|
{
|
||||||
|
provide: APP_CONFIG,
|
||||||
|
useValue: Object.assign(environment, {
|
||||||
|
markdown: {
|
||||||
|
enabled: true,
|
||||||
|
mathjax: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
markdownPipe = TestBed.inject(MarkdownPipe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render markdown', async () => {
|
||||||
|
await testTransform(
|
||||||
|
'# Header',
|
||||||
|
'<h1>Header</h1>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render mathjax', async () => {
|
||||||
|
await testTransform(
|
||||||
|
'$\\sqrt{2}^2$',
|
||||||
|
'<svg.*?>.*</svg>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render regular links', async () => {
|
||||||
|
await testTransform(
|
||||||
|
'<a href="https://www.dspace.com">DSpace</a>',
|
||||||
|
'<a href="https://www.dspace.com">DSpace</a>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render javascript links', async () => {
|
||||||
|
await testTransform(
|
||||||
|
'<a href="javascript:window.alert(\'bingo!\');">exploit</a>',
|
||||||
|
'<a>exploit</a>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function testTransform(input: string, output: string) {
|
||||||
|
expect(
|
||||||
|
await markdownPipe.transform(input)
|
||||||
|
).toMatch(
|
||||||
|
new RegExp('.*' + output + '.*')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
83
src/app/shared/utils/markdown.pipe.ts
Normal file
83
src/app/shared/utils/markdown.pipe.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Inject, InjectionToken, Pipe, PipeTransform } from '@angular/core';
|
||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import * as sanitizeHtml from 'sanitize-html';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
const mathjaxLoader = async () => (await import('markdown-it-mathjax3')).default;
|
||||||
|
type Mathjax = ReturnType<typeof mathjaxLoader>;
|
||||||
|
const MATHJAX = new InjectionToken<Mathjax>(
|
||||||
|
'Lazily loaded mathjax',
|
||||||
|
{ providedIn: 'root', factory: mathjaxLoader }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe for rendering markdown and mathjax.
|
||||||
|
* - markdown will only be rendered if {@link MarkdownConfig#enabled} is true
|
||||||
|
* - mathjax will only be rendered if both {@link MarkdownConfig#enabled} and {@link MarkdownConfig#mathjax} are true
|
||||||
|
*
|
||||||
|
* This pipe should be used on the 'innerHTML' attribute of a component, in combination with an async pipe.
|
||||||
|
* Example usage:
|
||||||
|
* <span class="example" [innerHTML]="'# title' | dsMarkdown | async"></span>
|
||||||
|
* Result:
|
||||||
|
* <span class="example">
|
||||||
|
* <h1>title</h1>
|
||||||
|
* </span>
|
||||||
|
*/
|
||||||
|
@Pipe({
|
||||||
|
name: 'dsMarkdown'
|
||||||
|
})
|
||||||
|
export class MarkdownPipe implements PipeTransform {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected sanitizer: DomSanitizer,
|
||||||
|
@Inject(MATHJAX) private mathjax: Mathjax,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
async transform(value: string): Promise<SafeHtml> {
|
||||||
|
if (!environment.markdown.enabled) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
});
|
||||||
|
if (environment.markdown.mathjax) {
|
||||||
|
md.use(await this.mathjax);
|
||||||
|
}
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(
|
||||||
|
sanitizeHtml(md.render(value), {
|
||||||
|
// sanitize-html doesn't let through SVG by default, so we extend its allowlists to cover MathJax SVG
|
||||||
|
allowedTags: [
|
||||||
|
...sanitizeHtml.defaults.allowedTags,
|
||||||
|
'mjx-container', 'svg', 'g', 'path', 'rect', 'text'
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
...sanitizeHtml.defaults.allowedAttributes,
|
||||||
|
'mjx-container': [
|
||||||
|
'class', 'style', 'jax'
|
||||||
|
],
|
||||||
|
svg: [
|
||||||
|
'xmlns', 'viewBox', 'style', 'width', 'height', 'role', 'focusable', 'alt', 'aria-label'
|
||||||
|
],
|
||||||
|
g: [
|
||||||
|
'data-mml-node', 'style', 'stroke', 'fill', 'stroke-width', 'transform'
|
||||||
|
],
|
||||||
|
path: [
|
||||||
|
'd', 'style', 'transform'
|
||||||
|
],
|
||||||
|
rect: [
|
||||||
|
'width', 'height', 'x', 'y', 'transform', 'style'
|
||||||
|
],
|
||||||
|
text: [
|
||||||
|
'transform', 'font-size'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
parser: {
|
||||||
|
lowerCaseAttributeNames: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,20 +1,26 @@
|
|||||||
|
import { Angulartics2GoogleAnalytics, Angulartics2GoogleTagManager } from 'angulartics2';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import { GoogleAnalyticsService } from './google-analytics.service';
|
import { GoogleAnalyticsService } from './google-analytics.service';
|
||||||
import { Angulartics2GoogleAnalytics } from 'angulartics2';
|
|
||||||
import { ConfigurationDataService } from '../core/data/configuration-data.service';
|
import { ConfigurationDataService } from '../core/data/configuration-data.service';
|
||||||
import {
|
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
|
||||||
createFailedRemoteDataObject$,
|
|
||||||
createSuccessfulRemoteDataObject$
|
|
||||||
} from '../shared/remote-data.utils';
|
|
||||||
import { ConfigurationProperty } from '../core/shared/configuration-property.model';
|
import { ConfigurationProperty } from '../core/shared/configuration-property.model';
|
||||||
|
import { KlaroService } from '../shared/cookies/klaro.service';
|
||||||
|
import { GOOGLE_ANALYTICS_KLARO_KEY } from '../shared/cookies/klaro-configuration';
|
||||||
|
|
||||||
describe('GoogleAnalyticsService', () => {
|
describe('GoogleAnalyticsService', () => {
|
||||||
const trackingIdProp = 'google.analytics.key';
|
const trackingIdProp = 'google.analytics.key';
|
||||||
const trackingIdTestValue = 'mock-tracking-id';
|
const trackingIdV4TestValue = 'G-mock-tracking-id';
|
||||||
|
const trackingIdV3TestValue = 'UA-mock-tracking-id';
|
||||||
const innerHTMLTestValue = 'mock-script-inner-html';
|
const innerHTMLTestValue = 'mock-script-inner-html';
|
||||||
|
const srcTestValue = 'mock-script-src';
|
||||||
let service: GoogleAnalyticsService;
|
let service: GoogleAnalyticsService;
|
||||||
let angularticsSpy: Angulartics2GoogleAnalytics;
|
let googleAnalyticsSpy: Angulartics2GoogleAnalytics;
|
||||||
|
let googleTagManagerSpy: Angulartics2GoogleTagManager;
|
||||||
let configSpy: ConfigurationDataService;
|
let configSpy: ConfigurationDataService;
|
||||||
|
let klaroServiceSpy: jasmine.SpyObj<KlaroService>;
|
||||||
let scriptElementMock: any;
|
let scriptElementMock: any;
|
||||||
|
let srcSpy: any;
|
||||||
let innerHTMLSpy: any;
|
let innerHTMLSpy: any;
|
||||||
let bodyElementSpy: HTMLBodyElement;
|
let bodyElementSpy: HTMLBodyElement;
|
||||||
let documentSpy: Document;
|
let documentSpy: Document;
|
||||||
@@ -28,18 +34,28 @@ describe('GoogleAnalyticsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
angularticsSpy = jasmine.createSpyObj('angulartics2GoogleAnalytics', [
|
googleAnalyticsSpy = jasmine.createSpyObj('Angulartics2GoogleAnalytics', [
|
||||||
|
'startTracking',
|
||||||
|
]);
|
||||||
|
googleTagManagerSpy = jasmine.createSpyObj('Angulartics2GoogleTagManager', [
|
||||||
'startTracking',
|
'startTracking',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
configSpy = createConfigSuccessSpy(trackingIdTestValue);
|
klaroServiceSpy = jasmine.createSpyObj('KlaroService', {
|
||||||
|
'getSavedPreferences': jasmine.createSpy('getSavedPreferences')
|
||||||
|
});
|
||||||
|
|
||||||
|
configSpy = createConfigSuccessSpy(trackingIdV4TestValue);
|
||||||
|
|
||||||
scriptElementMock = {
|
scriptElementMock = {
|
||||||
|
set src(newVal) { /* noop */ },
|
||||||
|
get src() { return innerHTMLTestValue; },
|
||||||
set innerHTML(newVal) { /* noop */ },
|
set innerHTML(newVal) { /* noop */ },
|
||||||
get innerHTML() { return innerHTMLTestValue; }
|
get innerHTML() { return srcTestValue; }
|
||||||
};
|
};
|
||||||
|
|
||||||
innerHTMLSpy = spyOnProperty(scriptElementMock, 'innerHTML', 'set');
|
innerHTMLSpy = spyOnProperty(scriptElementMock, 'innerHTML', 'set');
|
||||||
|
srcSpy = spyOnProperty(scriptElementMock, 'src', 'set');
|
||||||
|
|
||||||
bodyElementSpy = jasmine.createSpyObj('body', {
|
bodyElementSpy = jasmine.createSpyObj('body', {
|
||||||
appendChild: scriptElementMock,
|
appendChild: scriptElementMock,
|
||||||
@@ -51,7 +67,11 @@ describe('GoogleAnalyticsService', () => {
|
|||||||
body: bodyElementSpy,
|
body: bodyElementSpy,
|
||||||
});
|
});
|
||||||
|
|
||||||
service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy);
|
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
|
||||||
|
GOOGLE_ANALYTICS_KLARO_KEY: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy );
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
||||||
@@ -71,7 +91,11 @@ describe('GoogleAnalyticsService', () => {
|
|||||||
findByPropertyName: createFailedRemoteDataObject$(),
|
findByPropertyName: createFailedRemoteDataObject$(),
|
||||||
});
|
});
|
||||||
|
|
||||||
service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy);
|
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
|
||||||
|
GOOGLE_ANALYTICS_KLARO_KEY: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT add a script to the body', () => {
|
it('should NOT add a script to the body', () => {
|
||||||
@@ -81,7 +105,8 @@ describe('GoogleAnalyticsService', () => {
|
|||||||
|
|
||||||
it('should NOT start tracking', () => {
|
it('should NOT start tracking', () => {
|
||||||
service.addTrackingIdToPage();
|
service.addTrackingIdToPage();
|
||||||
expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(0);
|
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(0);
|
||||||
|
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +114,10 @@ describe('GoogleAnalyticsService', () => {
|
|||||||
describe('when the tracking id is empty', () => {
|
describe('when the tracking id is empty', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
configSpy = createConfigSuccessSpy();
|
configSpy = createConfigSuccessSpy();
|
||||||
service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy);
|
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
|
||||||
|
[GOOGLE_ANALYTICS_KLARO_KEY]: true
|
||||||
|
}));
|
||||||
|
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT add a script to the body', () => {
|
it('should NOT add a script to the body', () => {
|
||||||
@@ -99,11 +127,99 @@ describe('GoogleAnalyticsService', () => {
|
|||||||
|
|
||||||
it('should NOT start tracking', () => {
|
it('should NOT start tracking', () => {
|
||||||
service.addTrackingIdToPage();
|
service.addTrackingIdToPage();
|
||||||
expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(0);
|
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(0);
|
||||||
|
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when the tracking id is non-empty', () => {
|
describe('when google-analytics cookie preferences are not existing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configSpy = createConfigSuccessSpy(trackingIdV4TestValue);
|
||||||
|
klaroServiceSpy.getSavedPreferences.and.returnValue(of({}));
|
||||||
|
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT add a script to the body', () => {
|
||||||
|
service.addTrackingIdToPage();
|
||||||
|
expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT start tracking', () => {
|
||||||
|
service.addTrackingIdToPage();
|
||||||
|
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(0);
|
||||||
|
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('when google-analytics cookie preferences are set to false', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configSpy = createConfigSuccessSpy(trackingIdV4TestValue);
|
||||||
|
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
|
||||||
|
[GOOGLE_ANALYTICS_KLARO_KEY]: false
|
||||||
|
}));
|
||||||
|
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT add a script to the body', () => {
|
||||||
|
service.addTrackingIdToPage();
|
||||||
|
expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT start tracking', () => {
|
||||||
|
service.addTrackingIdToPage();
|
||||||
|
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(0);
|
||||||
|
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when both google-analytics cookie and the tracking v4 id are non-empty', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configSpy = createConfigSuccessSpy(trackingIdV4TestValue);
|
||||||
|
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
|
||||||
|
[GOOGLE_ANALYTICS_KLARO_KEY]: true
|
||||||
|
}));
|
||||||
|
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a script tag whose innerHTML contains the tracking id', () => {
|
||||||
|
service.addTrackingIdToPage();
|
||||||
|
expect(documentSpy.createElement).toHaveBeenCalledTimes(2);
|
||||||
|
expect(documentSpy.createElement).toHaveBeenCalledWith('script');
|
||||||
|
|
||||||
|
// sanity check
|
||||||
|
expect(documentSpy.createElement('script')).toBe(scriptElementMock);
|
||||||
|
|
||||||
|
expect(srcSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(srcSpy.calls.argsFor(0)[0]).toContain(trackingIdV4TestValue);
|
||||||
|
|
||||||
|
expect(innerHTMLSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(innerHTMLSpy.calls.argsFor(0)[0]).toContain(trackingIdV4TestValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a script to the body', () => {
|
||||||
|
service.addTrackingIdToPage();
|
||||||
|
expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start tracking', () => {
|
||||||
|
service.addTrackingIdToPage();
|
||||||
|
expect(googleAnalyticsSpy.startTracking).not.toHaveBeenCalled();
|
||||||
|
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when both google-analytics cookie and the tracking id v3 are non-empty', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configSpy = createConfigSuccessSpy(trackingIdV3TestValue);
|
||||||
|
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
|
||||||
|
[GOOGLE_ANALYTICS_KLARO_KEY]: true
|
||||||
|
}));
|
||||||
|
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
|
||||||
|
});
|
||||||
|
|
||||||
it('should create a script tag whose innerHTML contains the tracking id', () => {
|
it('should create a script tag whose innerHTML contains the tracking id', () => {
|
||||||
service.addTrackingIdToPage();
|
service.addTrackingIdToPage();
|
||||||
expect(documentSpy.createElement).toHaveBeenCalledTimes(1);
|
expect(documentSpy.createElement).toHaveBeenCalledTimes(1);
|
||||||
@@ -113,7 +229,7 @@ describe('GoogleAnalyticsService', () => {
|
|||||||
expect(documentSpy.createElement('script')).toBe(scriptElementMock);
|
expect(documentSpy.createElement('script')).toBe(scriptElementMock);
|
||||||
|
|
||||||
expect(innerHTMLSpy).toHaveBeenCalledTimes(1);
|
expect(innerHTMLSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(innerHTMLSpy.calls.argsFor(0)[0]).toContain(trackingIdTestValue);
|
expect(innerHTMLSpy.calls.argsFor(0)[0]).toContain(trackingIdV3TestValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add a script to the body', () => {
|
it('should add a script to the body', () => {
|
||||||
@@ -123,9 +239,12 @@ describe('GoogleAnalyticsService', () => {
|
|||||||
|
|
||||||
it('should start tracking', () => {
|
it('should start tracking', () => {
|
||||||
service.addTrackingIdToPage();
|
service.addTrackingIdToPage();
|
||||||
expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(1);
|
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(1);
|
||||||
|
expect(googleTagManagerSpy.startTracking).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,9 +1,14 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { Inject, Injectable } from '@angular/core';
|
import { Inject, Injectable } from '@angular/core';
|
||||||
import { Angulartics2GoogleAnalytics } from 'angulartics2';
|
|
||||||
|
import { Angulartics2GoogleAnalytics, Angulartics2GoogleTagManager } from 'angulartics2';
|
||||||
|
import { combineLatest } from 'rxjs';
|
||||||
|
|
||||||
import { ConfigurationDataService } from '../core/data/configuration-data.service';
|
import { ConfigurationDataService } from '../core/data/configuration-data.service';
|
||||||
import { getFirstCompletedRemoteData } from '../core/shared/operators';
|
import { getFirstCompletedRemoteData } from '../core/shared/operators';
|
||||||
import { isEmpty } from '../shared/empty.util';
|
import { isEmpty } from '../shared/empty.util';
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { KlaroService } from '../shared/cookies/klaro.service';
|
||||||
|
import { GOOGLE_ANALYTICS_KLARO_KEY } from '../shared/cookies/klaro-configuration';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up Google Analytics on the client side.
|
* Set up Google Analytics on the client side.
|
||||||
@@ -13,10 +18,13 @@ import { DOCUMENT } from '@angular/common';
|
|||||||
export class GoogleAnalyticsService {
|
export class GoogleAnalyticsService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private angulartics: Angulartics2GoogleAnalytics,
|
private googleAnalytics: Angulartics2GoogleAnalytics,
|
||||||
|
private googleTagManager: Angulartics2GoogleTagManager,
|
||||||
|
private klaroService: KlaroService,
|
||||||
private configService: ConfigurationDataService,
|
private configService: ConfigurationDataService,
|
||||||
@Inject(DOCUMENT) private document: any,
|
@Inject(DOCUMENT) private document: any,
|
||||||
) { }
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call this method once when Angular initializes on the client side.
|
* Call this method once when Angular initializes on the client side.
|
||||||
@@ -25,28 +33,61 @@ export class GoogleAnalyticsService {
|
|||||||
* page and starts tracking.
|
* page and starts tracking.
|
||||||
*/
|
*/
|
||||||
addTrackingIdToPage(): void {
|
addTrackingIdToPage(): void {
|
||||||
this.configService.findByPropertyName('google.analytics.key').pipe(
|
const googleKey$ = this.configService.findByPropertyName('google.analytics.key').pipe(
|
||||||
getFirstCompletedRemoteData(),
|
getFirstCompletedRemoteData(),
|
||||||
).subscribe((remoteData) => {
|
);
|
||||||
// make sure we got a success response from the backend
|
const preferences$ = this.klaroService.getSavedPreferences();
|
||||||
if (!remoteData.hasSucceeded) { return; }
|
|
||||||
|
|
||||||
const trackingId = remoteData.payload.values[0];
|
combineLatest([preferences$, googleKey$])
|
||||||
|
.subscribe(([preferences, remoteData]) => {
|
||||||
|
// make sure user has accepted Google Analytics consents
|
||||||
|
if (isEmpty(preferences) || isEmpty(preferences[GOOGLE_ANALYTICS_KLARO_KEY]) || !preferences[GOOGLE_ANALYTICS_KLARO_KEY]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// make sure we received a tracking id
|
// make sure we got a success response from the backend
|
||||||
if (isEmpty(trackingId)) { return; }
|
if (!remoteData.hasSucceeded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// add trackingId snippet to page
|
const trackingId = remoteData.payload.values[0];
|
||||||
const keyScript = this.document.createElement('script');
|
|
||||||
keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
// make sure we received a tracking id
|
||||||
|
if (isEmpty(trackingId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isGTagVersion(trackingId)) {
|
||||||
|
|
||||||
|
// add GTag snippet to page
|
||||||
|
const keyScript = this.document.createElement('script');
|
||||||
|
keyScript.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
|
||||||
|
this.document.body.appendChild(keyScript);
|
||||||
|
|
||||||
|
const libScript = this.document.createElement('script');
|
||||||
|
libScript.innerHTML = `window.dataLayer = window.dataLayer || [];function gtag(){window.dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());gtag('config', '${trackingId}');`;
|
||||||
|
this.document.body.appendChild(libScript);
|
||||||
|
|
||||||
|
// start tracking
|
||||||
|
this.googleTagManager.startTracking();
|
||||||
|
} else {
|
||||||
|
// add trackingId snippet to page
|
||||||
|
const keyScript = this.document.createElement('script');
|
||||||
|
keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||||
ga('create', '${trackingId}', 'auto');`;
|
ga('create', '${trackingId}', 'auto');`;
|
||||||
this.document.body.appendChild(keyScript);
|
this.document.body.appendChild(keyScript);
|
||||||
|
|
||||||
// start tracking
|
// start tracking
|
||||||
this.angulartics.startTracking();
|
this.googleAnalytics.startTracking();
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isGTagVersion(trackingId: string) {
|
||||||
|
return trackingId && trackingId.startsWith('G-');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -127,19 +127,18 @@ describe('ThumbnailComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const errorHandler = () => {
|
const errorHandler = () => {
|
||||||
let fallbackSpy;
|
let setSrcSpy;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fallbackSpy = spyOn(comp, 'showFallback').and.callThrough();
|
// disconnect error handler to be sure it's only called once
|
||||||
|
const img = fixture.debugElement.query(By.css('img.thumbnail-content'));
|
||||||
|
img.nativeNode.onerror = null;
|
||||||
|
|
||||||
|
comp.ngOnChanges();
|
||||||
|
setSrcSpy = spyOn(comp, 'setSrc').and.callThrough();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('retry with authentication token', () => {
|
describe('retry with authentication token', () => {
|
||||||
beforeEach(() => {
|
|
||||||
// disconnect error handler to be sure it's only called once
|
|
||||||
const img = fixture.debugElement.query(By.css('img.thumbnail-content'));
|
|
||||||
img.nativeNode.onerror = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remember that it already retried once', () => {
|
it('should remember that it already retried once', () => {
|
||||||
expect(comp.retriedWithToken).toBeFalse();
|
expect(comp.retriedWithToken).toBeFalse();
|
||||||
comp.errorHandler();
|
comp.errorHandler();
|
||||||
@@ -153,7 +152,7 @@ describe('ThumbnailComponent', () => {
|
|||||||
|
|
||||||
it('should fall back to default', () => {
|
it('should fall back to default', () => {
|
||||||
comp.errorHandler();
|
comp.errorHandler();
|
||||||
expect(fallbackSpy).toHaveBeenCalled();
|
expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,11 +171,9 @@ describe('ThumbnailComponent', () => {
|
|||||||
|
|
||||||
if ((comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
|
if ((comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
|
||||||
// If we failed to retrieve the Bitstream in the first place, fall back to the default
|
// If we failed to retrieve the Bitstream in the first place, fall back to the default
|
||||||
expect(comp.src$.getValue()).toBe(null);
|
expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage);
|
||||||
expect(fallbackSpy).toHaveBeenCalled();
|
|
||||||
} else {
|
} else {
|
||||||
expect(comp.src$.getValue()).toBe(CONTENT + '?authentication-token=fake');
|
expect(setSrcSpy).toHaveBeenCalledWith(CONTENT + '?authentication-token=fake');
|
||||||
expect(fallbackSpy).not.toHaveBeenCalled();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -189,8 +186,7 @@ describe('ThumbnailComponent', () => {
|
|||||||
it('should fall back to default', () => {
|
it('should fall back to default', () => {
|
||||||
comp.errorHandler();
|
comp.errorHandler();
|
||||||
|
|
||||||
expect(comp.src$.getValue()).toBe(null);
|
expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage);
|
||||||
expect(fallbackSpy).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// We don't need to check authorization if we failed to retrieve the Bitstreamin the first place
|
// We don't need to check authorization if we failed to retrieve the Bitstreamin the first place
|
||||||
if (!(comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
|
if (!(comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
|
||||||
@@ -210,7 +206,7 @@ describe('ThumbnailComponent', () => {
|
|||||||
comp.errorHandler();
|
comp.errorHandler();
|
||||||
expect(authService.isAuthenticated).not.toHaveBeenCalled();
|
expect(authService.isAuthenticated).not.toHaveBeenCalled();
|
||||||
expect(fileService.retrieveFileDownloadLink).not.toHaveBeenCalled();
|
expect(fileService.retrieveFileDownloadLink).not.toHaveBeenCalled();
|
||||||
expect(fallbackSpy).toHaveBeenCalled();
|
expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -263,21 +259,23 @@ describe('ThumbnailComponent', () => {
|
|||||||
comp.thumbnail = thumbnail;
|
comp.thumbnail = thumbnail;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display an image', () => {
|
describe('if content can be loaded', () => {
|
||||||
comp.ngOnChanges();
|
it('should display an image', () => {
|
||||||
fixture.detectChanges();
|
comp.ngOnChanges();
|
||||||
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
|
fixture.detectChanges();
|
||||||
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
|
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
|
||||||
|
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include the alt text', () => {
|
||||||
|
comp.ngOnChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
|
||||||
|
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include the alt text', () => {
|
describe('if content can\'t be loaded', () => {
|
||||||
comp.ngOnChanges();
|
|
||||||
fixture.detectChanges();
|
|
||||||
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
|
|
||||||
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when there is no thumbnail', () => {
|
|
||||||
errorHandler();
|
errorHandler();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -296,36 +294,42 @@ describe('ThumbnailComponent', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when there is a thumbnail', () => {
|
describe('if RemoteData succeeded', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail);
|
comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display an image', () => {
|
describe('if content can be loaded', () => {
|
||||||
comp.ngOnChanges();
|
it('should display an image', () => {
|
||||||
fixture.detectChanges();
|
comp.ngOnChanges();
|
||||||
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
fixture.detectChanges();
|
||||||
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
|
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
||||||
|
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display the alt text', () => {
|
||||||
|
comp.ngOnChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
||||||
|
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display the alt text', () => {
|
describe('if content can\'t be loaded', () => {
|
||||||
comp.ngOnChanges();
|
|
||||||
fixture.detectChanges();
|
|
||||||
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
|
||||||
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('but it can\'t be loaded', () => {
|
|
||||||
errorHandler();
|
errorHandler();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when there is no thumbnail', () => {
|
describe('if RemoteData failed', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
comp.thumbnail = createFailedRemoteDataObject();
|
comp.thumbnail = createFailedRemoteDataObject();
|
||||||
});
|
});
|
||||||
|
|
||||||
errorHandler();
|
it('should show the default image', () => {
|
||||||
|
comp.defaultImage = 'default/image.jpg';
|
||||||
|
comp.ngOnChanges();
|
||||||
|
expect(comp.src$.getValue()).toBe('default/image.jpg');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -12,7 +12,7 @@ import { FileService } from '../core/shared/file.service';
|
|||||||
/**
|
/**
|
||||||
* This component renders a given Bitstream as a thumbnail.
|
* This component renders a given Bitstream as a thumbnail.
|
||||||
* One input parameter of type Bitstream is expected.
|
* One input parameter of type Bitstream is expected.
|
||||||
* If no Bitstream is provided, a HTML placeholder will be rendered instead.
|
* If no Bitstream is provided, an HTML placeholder will be rendered instead.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-thumbnail',
|
selector: 'ds-thumbnail',
|
||||||
@@ -75,11 +75,11 @@ export class ThumbnailComponent implements OnChanges {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbnail = this.bitstream;
|
const src = this.contentHref;
|
||||||
if (hasValue(thumbnail?._links?.content?.href)) {
|
if (hasValue(src)) {
|
||||||
this.setSrc(thumbnail?._links?.content?.href);
|
this.setSrc(src);
|
||||||
} else {
|
} else {
|
||||||
this.showFallback();
|
this.setSrc(this.defaultImage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,22 +95,33 @@ export class ThumbnailComponent implements OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get contentHref(): string | undefined {
|
||||||
|
if (this.thumbnail instanceof Bitstream) {
|
||||||
|
return this.thumbnail?._links?.content?.href;
|
||||||
|
} else if (this.thumbnail instanceof RemoteData) {
|
||||||
|
return this.thumbnail?.payload?._links?.content?.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle image download errors.
|
* Handle image download errors.
|
||||||
* If the image can't be loaded, try re-requesting it with an authorization token in case it's a restricted Bitstream
|
* If the image can't be loaded, try re-requesting it with an authorization token in case it's a restricted Bitstream
|
||||||
* Otherwise, fall back to the default image or a HTML placeholder
|
* Otherwise, fall back to the default image or a HTML placeholder
|
||||||
*/
|
*/
|
||||||
errorHandler() {
|
errorHandler() {
|
||||||
if (!this.retriedWithToken && hasValue(this.thumbnail)) {
|
const src = this.src$.getValue();
|
||||||
|
const thumbnail = this.bitstream;
|
||||||
|
const thumbnailSrc = thumbnail?._links?.content?.href;
|
||||||
|
|
||||||
|
if (!this.retriedWithToken && hasValue(thumbnailSrc) && src === thumbnailSrc) {
|
||||||
// the thumbnail may have failed to load because it's restricted
|
// the thumbnail may have failed to load because it's restricted
|
||||||
// → retry with an authorization token
|
// → retry with an authorization token
|
||||||
// only do this once; fall back to the default if it still fails
|
// only do this once; fall back to the default if it still fails
|
||||||
this.retriedWithToken = true;
|
this.retriedWithToken = true;
|
||||||
|
|
||||||
const thumbnail = this.bitstream;
|
|
||||||
this.auth.isAuthenticated().pipe(
|
this.auth.isAuthenticated().pipe(
|
||||||
switchMap((isLoggedIn) => {
|
switchMap((isLoggedIn) => {
|
||||||
if (isLoggedIn && hasValue(thumbnail)) {
|
if (isLoggedIn) {
|
||||||
return this.authorizationService.isAuthorized(FeatureID.CanDownload, thumbnail.self);
|
return this.authorizationService.isAuthorized(FeatureID.CanDownload, thumbnail.self);
|
||||||
} else {
|
} else {
|
||||||
return observableOf(false);
|
return observableOf(false);
|
||||||
@@ -118,7 +129,7 @@ export class ThumbnailComponent implements OnChanges {
|
|||||||
}),
|
}),
|
||||||
switchMap((isAuthorized) => {
|
switchMap((isAuthorized) => {
|
||||||
if (isAuthorized) {
|
if (isAuthorized) {
|
||||||
return this.fileService.retrieveFileDownloadLink(thumbnail._links.content.href);
|
return this.fileService.retrieveFileDownloadLink(thumbnailSrc);
|
||||||
} else {
|
} else {
|
||||||
return observableOf(null);
|
return observableOf(null);
|
||||||
}
|
}
|
||||||
@@ -130,27 +141,17 @@ export class ThumbnailComponent implements OnChanges {
|
|||||||
// Otherwise, fall back to the default image right now
|
// Otherwise, fall back to the default image right now
|
||||||
this.setSrc(url);
|
this.setSrc(url);
|
||||||
} else {
|
} else {
|
||||||
this.showFallback();
|
this.setSrc(this.defaultImage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.showFallback();
|
if (src !== this.defaultImage) {
|
||||||
}
|
// we failed to get thumbnail (possibly retried with a token but failed again)
|
||||||
}
|
this.setSrc(this.defaultImage);
|
||||||
|
} else {
|
||||||
/**
|
// we have failed to retrieve the default image, fall back to the placeholder
|
||||||
* To be called when the requested thumbnail could not be found
|
this.setSrc(null);
|
||||||
* - If the current src is not the default image, try that first
|
}
|
||||||
* - If this was already the case and the default image could not be found either,
|
|
||||||
* show an HTML placecholder by setting src to null
|
|
||||||
*
|
|
||||||
* Also stops the loading animation.
|
|
||||||
*/
|
|
||||||
showFallback() {
|
|
||||||
if (this.src$.getValue() !== this.defaultImage) {
|
|
||||||
this.setSrc(this.defaultImage);
|
|
||||||
} else {
|
|
||||||
this.setSrc(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2734,8 +2734,6 @@
|
|||||||
|
|
||||||
"mydspace.description": "",
|
"mydspace.description": "",
|
||||||
|
|
||||||
"mydspace.general.text-here": "here",
|
|
||||||
|
|
||||||
"mydspace.messages.controller-help": "Select this option to send a message to item's submitter.",
|
"mydspace.messages.controller-help": "Select this option to send a message to item's submitter.",
|
||||||
|
|
||||||
"mydspace.messages.description-placeholder": "Insert your message here...",
|
"mydspace.messages.description-placeholder": "Insert your message here...",
|
||||||
@@ -2812,8 +2810,6 @@
|
|||||||
|
|
||||||
"mydspace.upload.upload-multiple-successful": "{{qty}} new workspace items created.",
|
"mydspace.upload.upload-multiple-successful": "{{qty}} new workspace items created.",
|
||||||
|
|
||||||
"mydspace.upload.upload-successful": "New workspace item created. Click {{here}} for edit it.",
|
|
||||||
|
|
||||||
"mydspace.view-btn": "View",
|
"mydspace.view-btn": "View",
|
||||||
|
|
||||||
|
|
||||||
@@ -3081,12 +3077,16 @@
|
|||||||
|
|
||||||
"profile.security.form.label.passwordrepeat": "Retype to confirm",
|
"profile.security.form.label.passwordrepeat": "Retype to confirm",
|
||||||
|
|
||||||
|
"profile.security.form.label.current-password": "Current password",
|
||||||
|
|
||||||
"profile.security.form.notifications.success.content": "Your changes to the password were saved.",
|
"profile.security.form.notifications.success.content": "Your changes to the password were saved.",
|
||||||
|
|
||||||
"profile.security.form.notifications.success.title": "Password saved",
|
"profile.security.form.notifications.success.title": "Password saved",
|
||||||
|
|
||||||
"profile.security.form.notifications.error.title": "Error changing passwords",
|
"profile.security.form.notifications.error.title": "Error changing passwords",
|
||||||
|
|
||||||
|
"profile.security.form.notifications.error.change-failed": "An error occurred while trying to change the password. Please check if the current password is correct.",
|
||||||
|
|
||||||
"profile.security.form.notifications.error.not-same": "The provided passwords are not the same.",
|
"profile.security.form.notifications.error.not-same": "The provided passwords are not the same.",
|
||||||
|
|
||||||
"profile.security.form.notifications.error.general": "Please fill required fields of security form.",
|
"profile.security.form.notifications.error.general": "Please fill required fields of security form.",
|
||||||
|
@@ -3627,9 +3627,6 @@
|
|||||||
// "mydspace.description": "",
|
// "mydspace.description": "",
|
||||||
"mydspace.description": "",
|
"mydspace.description": "",
|
||||||
|
|
||||||
// "mydspace.general.text-here": "here",
|
|
||||||
"mydspace.general.text-here": "ici",
|
|
||||||
|
|
||||||
// "mydspace.messages.controller-help": "Select this option to send a message to item's submitter.",
|
// "mydspace.messages.controller-help": "Select this option to send a message to item's submitter.",
|
||||||
"mydspace.messages.controller-help": "Sélectionner cette option pour envoyer un message au déposant.",
|
"mydspace.messages.controller-help": "Sélectionner cette option pour envoyer un message au déposant.",
|
||||||
|
|
||||||
@@ -3744,9 +3741,6 @@
|
|||||||
// "mydspace.upload.upload-multiple-successful": "{{qty}} new workspace items created.",
|
// "mydspace.upload.upload-multiple-successful": "{{qty}} new workspace items created.",
|
||||||
"mydspace.upload.upload-multiple-successful": "{{qty}} nouveaux Items créés dans l'espace de travail.",
|
"mydspace.upload.upload-multiple-successful": "{{qty}} nouveaux Items créés dans l'espace de travail.",
|
||||||
|
|
||||||
// "mydspace.upload.upload-successful": "New workspace item created. Click {{here}} for edit it.",
|
|
||||||
"mydspace.upload.upload-successful": "Nouvel item créé dans l'espace de travail. Cliquer {{here}} pour l'éditer.",
|
|
||||||
|
|
||||||
// "mydspace.view-btn": "View",
|
// "mydspace.view-btn": "View",
|
||||||
"mydspace.view-btn": "Afficher",
|
"mydspace.view-btn": "Afficher",
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import { ActuatorsConfig } from './actuators.config';
|
|||||||
import { InfoConfig } from './info-config.interface';
|
import { InfoConfig } from './info-config.interface';
|
||||||
import { CommunityListConfig } from './community-list-config.interface';
|
import { CommunityListConfig } from './community-list-config.interface';
|
||||||
import { HomeConfig } from './homepage-config.interface';
|
import { HomeConfig } from './homepage-config.interface';
|
||||||
|
import { MarkdownConfig } from './markdown-config.interface';
|
||||||
|
|
||||||
interface AppConfig extends Config {
|
interface AppConfig extends Config {
|
||||||
ui: UIServerConfig;
|
ui: UIServerConfig;
|
||||||
@@ -42,6 +43,7 @@ interface AppConfig extends Config {
|
|||||||
bundle: BundleConfig;
|
bundle: BundleConfig;
|
||||||
actuators: ActuatorsConfig
|
actuators: ActuatorsConfig
|
||||||
info: InfoConfig;
|
info: InfoConfig;
|
||||||
|
markdown: MarkdownConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -10,6 +10,7 @@ describe('Config Util', () => {
|
|||||||
expect(appConfig.cache.msToLive.default).toEqual(15 * 60 * 1000); // 15 minute
|
expect(appConfig.cache.msToLive.default).toEqual(15 * 60 * 1000); // 15 minute
|
||||||
expect(appConfig.ui.rateLimiter.windowMs).toEqual(1 * 60 * 1000); // 1 minute
|
expect(appConfig.ui.rateLimiter.windowMs).toEqual(1 * 60 * 1000); // 1 minute
|
||||||
expect(appConfig.ui.rateLimiter.max).toEqual(500);
|
expect(appConfig.ui.rateLimiter.max).toEqual(500);
|
||||||
|
expect(appConfig.ui.useProxies).toEqual(true);
|
||||||
|
|
||||||
expect(appConfig.submission.autosave.metadata).toEqual([]);
|
expect(appConfig.submission.autosave.metadata).toEqual([]);
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ describe('Config Util', () => {
|
|||||||
};
|
};
|
||||||
appConfig.ui.rateLimiter = rateLimiter;
|
appConfig.ui.rateLimiter = rateLimiter;
|
||||||
|
|
||||||
|
appConfig.ui.useProxies = false;
|
||||||
|
|
||||||
const autoSaveMetadata = [
|
const autoSaveMetadata = [
|
||||||
'dc.author',
|
'dc.author',
|
||||||
'dc.title'
|
'dc.title'
|
||||||
@@ -44,6 +47,7 @@ describe('Config Util', () => {
|
|||||||
expect(environment.cache.msToLive.default).toEqual(msToLive);
|
expect(environment.cache.msToLive.default).toEqual(msToLive);
|
||||||
expect(environment.ui.rateLimiter.windowMs).toEqual(rateLimiter.windowMs);
|
expect(environment.ui.rateLimiter.windowMs).toEqual(rateLimiter.windowMs);
|
||||||
expect(environment.ui.rateLimiter.max).toEqual(rateLimiter.max);
|
expect(environment.ui.rateLimiter.max).toEqual(rateLimiter.max);
|
||||||
|
expect(environment.ui.useProxies).toEqual(false);
|
||||||
expect(environment.submission.autosave.metadata[0]).toEqual(autoSaveMetadata[0]);
|
expect(environment.submission.autosave.metadata[0]).toEqual(autoSaveMetadata[0]);
|
||||||
expect(environment.submission.autosave.metadata[1]).toEqual(autoSaveMetadata[1]);
|
expect(environment.submission.autosave.metadata[1]).toEqual(autoSaveMetadata[1]);
|
||||||
|
|
||||||
|
@@ -19,6 +19,7 @@ import { ActuatorsConfig } from './actuators.config';
|
|||||||
import { InfoConfig } from './info-config.interface';
|
import { InfoConfig } from './info-config.interface';
|
||||||
import { CommunityListConfig } from './community-list-config.interface';
|
import { CommunityListConfig } from './community-list-config.interface';
|
||||||
import { HomeConfig } from './homepage-config.interface';
|
import { HomeConfig } from './homepage-config.interface';
|
||||||
|
import { MarkdownConfig } from './markdown-config.interface';
|
||||||
|
|
||||||
export class DefaultAppConfig implements AppConfig {
|
export class DefaultAppConfig implements AppConfig {
|
||||||
production = false;
|
production = false;
|
||||||
@@ -39,7 +40,10 @@ export class DefaultAppConfig implements AppConfig {
|
|||||||
rateLimiter: {
|
rateLimiter: {
|
||||||
windowMs: 1 * 60 * 1000, // 1 minute
|
windowMs: 1 * 60 * 1000, // 1 minute
|
||||||
max: 500 // limit each IP to 500 requests per windowMs
|
max: 500 // limit each IP to 500 requests per windowMs
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// Trust X-FORWARDED-* headers from proxies
|
||||||
|
useProxies: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The REST API server settings
|
// The REST API server settings
|
||||||
@@ -364,4 +368,9 @@ export class DefaultAppConfig implements AppConfig {
|
|||||||
enableEndUserAgreement: true,
|
enableEndUserAgreement: true,
|
||||||
enablePrivacyStatement: true
|
enablePrivacyStatement: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
markdown: MarkdownConfig = {
|
||||||
|
enabled: false,
|
||||||
|
mathjax: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
21
src/config/markdown-config.interface.ts
Normal file
21
src/config/markdown-config.interface.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Config } from './config.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config related to the {@link MarkdownPipe}.
|
||||||
|
*/
|
||||||
|
export interface MarkdownConfig extends Config {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable Markdown (https://commonmark.org/) syntax for values passed to the {@link MarkdownPipe}.
|
||||||
|
* - If this is true, values passed to the MarkdownPipe will be transformed to html according to the markdown syntax
|
||||||
|
* rules.
|
||||||
|
* - If this is false, using the MarkdownPipe will have no effect.
|
||||||
|
*/
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable MathJax (https://www.mathjax.org/) syntax for values passed to the {@link MarkdownPipe}.
|
||||||
|
* Requires {@link enabled} to also be true before MathJax will display.
|
||||||
|
*/
|
||||||
|
mathjax: boolean;
|
||||||
|
}
|
@@ -11,4 +11,6 @@ export class UIServerConfig extends ServerConfig {
|
|||||||
max: number;
|
max: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Trust X-FORWARDED-* headers from proxies
|
||||||
|
useProxies: boolean;
|
||||||
}
|
}
|
||||||
|
@@ -25,7 +25,8 @@ export const environment: BuildConfig = {
|
|||||||
rateLimiter: {
|
rateLimiter: {
|
||||||
windowMs: 1 * 60 * 1000, // 1 minute
|
windowMs: 1 * 60 * 1000, // 1 minute
|
||||||
max: 500 // limit each IP to 500 requests per windowMs
|
max: 500 // limit each IP to 500 requests per windowMs
|
||||||
}
|
},
|
||||||
|
useProxies: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// The REST API server settings.
|
// The REST API server settings.
|
||||||
@@ -271,4 +272,8 @@ export const environment: BuildConfig = {
|
|||||||
enableEndUserAgreement: true,
|
enableEndUserAgreement: true,
|
||||||
enablePrivacyStatement: true,
|
enablePrivacyStatement: true,
|
||||||
},
|
},
|
||||||
|
markdown: {
|
||||||
|
enabled: false,
|
||||||
|
mathjax: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@@ -15,13 +15,17 @@ import { AppModule } from '../../app/app.module';
|
|||||||
import { ClientCookieService } from '../../app/core/services/client-cookie.service';
|
import { ClientCookieService } from '../../app/core/services/client-cookie.service';
|
||||||
import { CookieService } from '../../app/core/services/cookie.service';
|
import { CookieService } from '../../app/core/services/cookie.service';
|
||||||
import { AuthService } from '../../app/core/auth/auth.service';
|
import { AuthService } from '../../app/core/auth/auth.service';
|
||||||
import { Angulartics2RouterlessModule } from 'angulartics2';
|
import { Angulartics2GoogleTagManager, Angulartics2RouterlessModule } from 'angulartics2';
|
||||||
import { SubmissionService } from '../../app/submission/submission.service';
|
import { SubmissionService } from '../../app/submission/submission.service';
|
||||||
import { StatisticsModule } from '../../app/statistics/statistics.module';
|
import { StatisticsModule } from '../../app/statistics/statistics.module';
|
||||||
import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service';
|
import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service';
|
||||||
import { KlaroService } from '../../app/shared/cookies/klaro.service';
|
import { KlaroService } from '../../app/shared/cookies/klaro.service';
|
||||||
import { HardRedirectService } from '../../app/core/services/hard-redirect.service';
|
import { HardRedirectService } from '../../app/core/services/hard-redirect.service';
|
||||||
import { BrowserHardRedirectService, locationProvider, LocationToken } from '../../app/core/services/browser-hard-redirect.service';
|
import {
|
||||||
|
BrowserHardRedirectService,
|
||||||
|
locationProvider,
|
||||||
|
LocationToken
|
||||||
|
} from '../../app/core/services/browser-hard-redirect.service';
|
||||||
import { LocaleService } from '../../app/core/locale/locale.service';
|
import { LocaleService } from '../../app/core/locale/locale.service';
|
||||||
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
|
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
|
||||||
import { AuthRequestService } from '../../app/core/auth/auth-request.service';
|
import { AuthRequestService } from '../../app/core/auth/auth-request.service';
|
||||||
@@ -95,6 +99,10 @@ export function getRequest(transferState: TransferState): any {
|
|||||||
provide: GoogleAnalyticsService,
|
provide: GoogleAnalyticsService,
|
||||||
useClass: GoogleAnalyticsService,
|
useClass: GoogleAnalyticsService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: Angulartics2GoogleTagManager,
|
||||||
|
useClass: Angulartics2GoogleTagManager
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: AuthRequestService,
|
provide: AuthRequestService,
|
||||||
useClass: BrowserAuthRequestService,
|
useClass: BrowserAuthRequestService,
|
||||||
|
@@ -6,8 +6,7 @@ import { ServerModule, ServerTransferStateModule } from '@angular/platform-serve
|
|||||||
|
|
||||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
import { Angulartics2 } from 'angulartics2';
|
import { Angulartics2, Angulartics2GoogleAnalytics, Angulartics2GoogleTagManager } from 'angulartics2';
|
||||||
import { Angulartics2GoogleAnalytics } from 'angulartics2';
|
|
||||||
|
|
||||||
import { AppComponent } from '../../app/app.component';
|
import { AppComponent } from '../../app/app.component';
|
||||||
|
|
||||||
@@ -63,6 +62,10 @@ export function createTranslateLoader(transferState: TransferState) {
|
|||||||
provide: Angulartics2GoogleAnalytics,
|
provide: Angulartics2GoogleAnalytics,
|
||||||
useClass: AngularticsProviderMock
|
useClass: AngularticsProviderMock
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: Angulartics2GoogleTagManager,
|
||||||
|
useClass: AngularticsProviderMock
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: Angulartics2DSpace,
|
provide: Angulartics2DSpace,
|
||||||
useClass: AngularticsProviderMock
|
useClass: AngularticsProviderMock
|
||||||
|
@@ -187,6 +187,10 @@ ds-dynamic-form-control-container.d-none {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preserve-line-breaks {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
ul.dso-edit-menu-dropdown > li .nav-item.nav-link {
|
ul.dso-edit-menu-dropdown > li .nav-item.nav-link {
|
||||||
// ensure that links in DSO edit menu dropdowns are unstyled (li elements are styled instead to support icons)
|
// ensure that links in DSO edit menu dropdowns are unstyled (li elements are styled instead to support icons)
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@@ -0,0 +1,17 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { BrowseByDatePageComponent as BaseComponent } from '../../../../../app/browse-by/browse-by-date-page/browse-by-date-page.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-browse-by-date-page',
|
||||||
|
// styleUrls: ['./browse-by-date-page.component.scss'],
|
||||||
|
styleUrls: ['../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss'],
|
||||||
|
// templateUrl: './browse-by-date-page.component.html'
|
||||||
|
templateUrl: '../../../../../app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for determining what Browse-By component to use depending on the metadata (browse ID) provided
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class BrowseByDatePageComponent extends BaseComponent {
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user