Merge remote-tracking branch 'origin/main' into #1206

# Conflicts:
#	src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts
#	src/app/submission/form/submission-upload-files/submission-upload-files.component.ts
This commit is contained in:
Giuseppe Digilio
2021-07-20 11:26:06 +02:00
109 changed files with 2375 additions and 878 deletions

View File

@@ -107,6 +107,6 @@ describe('BrowseByDatePageComponent', () => {
}); });
it('should create a list of startsWith options with the current year first', () => { it('should create a list of startsWith options with the current year first', () => {
expect(comp.startsWithOptions[0]).toEqual(new Date().getFullYear()); expect(comp.startsWithOptions[0]).toEqual(new Date().getUTCFullYear());
}); });
}); });

View File

@@ -92,7 +92,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
} }
} }
const options = []; const options = [];
const currentYear = new Date().getFullYear(); const currentYear = new Date().getUTCFullYear();
const oneYearBreak = Math.floor((currentYear - environment.browseBy.oneYearLimit) / 5) * 5; const oneYearBreak = Math.floor((currentYear - environment.browseBy.oneYearLimit) / 5) * 5;
const fiveYearBreak = Math.floor((currentYear - environment.browseBy.fiveYearLimit) / 10) * 10; const fiveYearBreak = Math.floor((currentYear - environment.browseBy.fiveYearLimit) / 10) * 10;
if (lowerLimit <= fiveYearBreak) { if (lowerLimit <= fiveYearBreak) {

View File

@@ -167,7 +167,6 @@ export class BrowseByMetadataPageComponent implements OnInit {
* @param value The value of the browse-entry to display items for * @param value The value of the browse-entry to display items for
*/ */
updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string) { updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string) {
console.log('updatePAge', searchOptions);
this.items$ = this.browseService.getBrowseItemsFor(value, searchOptions); this.items$ = this.browseService.getBrowseItemsFor(value, searchOptions);
} }

View File

@@ -42,6 +42,7 @@
[key]="'map'" [key]="'map'"
[dsoRD$]="mappedItemsRD$" [dsoRD$]="mappedItemsRD$"
[paginationOptions]="(searchOptions$ | async)?.pagination" [paginationOptions]="(searchOptions$ | async)?.pagination"
[featureId]="FeatureIds.CanManageMappings"
[confirmButton]="'collection.edit.item-mapper.confirm'" [confirmButton]="'collection.edit.item-mapper.confirm'"
[cancelButton]="'collection.edit.item-mapper.cancel'" [cancelButton]="'collection.edit.item-mapper.cancel'"
(confirm)="mapItems($event)" (confirm)="mapItems($event)"

View File

@@ -40,6 +40,7 @@ import {
createSuccessfulRemoteDataObject$ createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils'; } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
describe('CollectionItemMapperComponent', () => { describe('CollectionItemMapperComponent', () => {
let comp: CollectionItemMapperComponent; let comp: CollectionItemMapperComponent;
@@ -136,6 +137,10 @@ describe('CollectionItemMapperComponent', () => {
} }
}; };
const authorizationDataService = jasmine.createSpyObj('authorizationDataService', {
isAuthorized: observableOf(true)
});
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
@@ -152,6 +157,7 @@ describe('CollectionItemMapperComponent', () => {
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() },
{ provide: RouteService, useValue: routeServiceStub }, { provide: RouteService, useValue: routeServiceStub },
{ provide: AuthorizationDataService, useValue: authorizationDataService }
] ]
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -28,6 +28,7 @@ import { PaginatedSearchOptions } from '../../shared/search/paginated-search-opt
import { SearchService } from '../../core/shared/search/search.service'; import { SearchService } from '../../core/shared/search/search.service';
import { followLink } from '../../shared/utils/follow-link-config.model'; import { followLink } from '../../shared/utils/follow-link-config.model';
import { NoContent } from '../../core/shared/NoContent.model'; import { NoContent } from '../../core/shared/NoContent.model';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
@Component({ @Component({
selector: 'ds-collection-item-mapper', selector: 'ds-collection-item-mapper',
@@ -50,6 +51,8 @@ import { NoContent } from '../../core/shared/NoContent.model';
*/ */
export class CollectionItemMapperComponent implements OnInit { export class CollectionItemMapperComponent implements OnInit {
FeatureIds = FeatureID;
/** /**
* A view on the tabset element * A view on the tabset element
* Used to switch tabs programmatically * Used to switch tabs programmatically

View File

@@ -1,6 +1,11 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subject } from 'rxjs'; import {
BehaviorSubject,
combineLatest as observableCombineLatest,
Observable,
Subject
} from 'rxjs';
import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators'; import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators';
import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model';
import { SearchService } from '../core/shared/search/search.service'; import { SearchService } from '../core/shared/search/search.service';
@@ -8,8 +13,6 @@ import { SortDirection, SortOptions } from '../core/cache/models/sort-options.mo
import { CollectionDataService } from '../core/data/collection-data.service'; import { CollectionDataService } from '../core/data/collection-data.service';
import { PaginatedList } from '../core/data/paginated-list.model'; import { PaginatedList } from '../core/data/paginated-list.model';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
import { MetadataService } from '../core/metadata/metadata.service';
import { Bitstream } from '../core/shared/bitstream.model'; import { Bitstream } from '../core/shared/bitstream.model';
import { Collection } from '../core/shared/collection.model'; import { Collection } from '../core/shared/collection.model';
@@ -65,7 +68,6 @@ export class CollectionPageComponent implements OnInit {
constructor( constructor(
private collectionDataService: CollectionDataService, private collectionDataService: CollectionDataService,
private searchService: SearchService, private searchService: SearchService,
private metadata: MetadataService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private authService: AuthService, private authService: AuthService,
@@ -122,10 +124,6 @@ export class CollectionPageComponent implements OnInit {
getAllSucceededRemoteDataPayload(), getAllSucceededRemoteDataPayload(),
map((collection) => getCollectionPageRoute(collection.id)) map((collection) => getCollectionPageRoute(collection.id))
); );
this.route.queryParams.pipe(take(1)).subscribe((params) => {
this.metadata.processRemoteData(this.collectionRD$);
});
} }
isNotEmpty(object: any) { isNotEmpty(object: any) {

View File

@@ -19,11 +19,15 @@
.drag-handle { .drag-handle {
visibility: hidden; visibility: hidden;
&:hover { &:hover {
cursor: grab; cursor: move;
} }
} }
:host ::ng-deep .bitstream-row:hover .drag-handle { .bitstream-row-drag-handle:hover {
cursor: move;
}
:host ::ng-deep .bitstream-row:hover .drag-handle, :host ::ng-deep .bitstream-row-drag-handle:focus .drag-handle {
visibility: visible !important; visibility: visible !important;
} }
@@ -40,3 +44,9 @@
.cdk-drag-animating { .cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
} }
.cdk-drop-list-dragging {
.bitstream-row-drag-handle, .drag-handle {
cursor: grabbing;
}
}

View File

@@ -21,7 +21,7 @@
<ds-item-edit-bitstream [fieldUpdate]="updates[uuid]" <ds-item-edit-bitstream [fieldUpdate]="updates[uuid]"
[bundleUrl]="bundle.self" [bundleUrl]="bundle.self"
[columnSizes]="columnSizes"> [columnSizes]="columnSizes">
<div class="d-flex align-items-center" slot="drag-handle" cdkDragHandle> <div class="d-flex align-items-center bitstream-row-drag-handle" slot="drag-handle" cdkDragHandle tabindex="0">
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle> <ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
</div> </div>
</ds-item-edit-bitstream> </ds-item-edit-bitstream>

View File

@@ -1,5 +1,5 @@
<ng-template #handleView> <ng-template #handleView>
<div class="drag-handle text-muted float-left p-1 mr-2"> <div class="drag-handle text-muted float-left p-1 mr-2" tabindex="0">
<i class="fas fa-grip-vertical fa-fw" [title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i> <i class="fas fa-grip-vertical fa-fw" [title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i>
</div> </div>
</ng-template> </ng-template>

View File

@@ -40,6 +40,7 @@ import {
createSuccessfulRemoteDataObject$ createSuccessfulRemoteDataObject$
} from '../../../shared/remote-data.utils'; } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../shared/testing/utils.test';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
describe('ItemCollectionMapperComponent', () => { describe('ItemCollectionMapperComponent', () => {
let comp: ItemCollectionMapperComponent; let comp: ItemCollectionMapperComponent;
@@ -110,6 +111,10 @@ describe('ItemCollectionMapperComponent', () => {
onDefaultLangChange: new EventEmitter() onDefaultLangChange: new EventEmitter()
}; };
const authorizationDataService = jasmine.createSpyObj('authorizationDataService', {
isAuthorized: observableOf(true)
});
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
@@ -124,7 +129,8 @@ describe('ItemCollectionMapperComponent', () => {
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() },
{ provide: TranslateService, useValue: translateServiceStub }, { provide: TranslateService, useValue: translateServiceStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: CollectionDataService, useValue: collectionDataServiceStub } { provide: CollectionDataService, useValue: collectionDataServiceStub },
{ provide: AuthorizationDataService, useValue: authorizationDataService }
] ]
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -29,7 +29,7 @@
<span>{{metadata?.value}}</span> <span>{{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" [(ngModel)]="metadata.value" [dsDebounce] <textarea class="form-control" type="textarea" attr.aria-labelledby="fieldValue" [(ngModel)]="metadata.value" [dsDebounce]
(onDebounce)="update()"></textarea> (onDebounce)="update()"></textarea>
</div> </div>
</div> </div>
@@ -40,7 +40,7 @@
<span>{{metadata?.language}}</span> <span>{{metadata?.language}}</span>
</div> </div>
<div *ngIf="(editable | async)" class="field-container"> <div *ngIf="(editable | async)" class="field-container">
<input class="form-control" type="text" [(ngModel)]="metadata.language" [dsDebounce] <input class="form-control" type="text" attr.aria-labelledby="fieldLang" [(ngModel)]="metadata.language" [dsDebounce]
(onDebounce)="update()"/> (onDebounce)="update()"/>
</div> </div>
</div> </div>

View File

@@ -25,9 +25,9 @@
<table class="table table-responsive table-striped table-bordered" *ngIf="((updates$ | async)| dsObjectValues).length > 0"> <table class="table table-responsive table-striped table-bordered" *ngIf="((updates$ | async)| dsObjectValues).length > 0">
<tbody> <tbody>
<tr> <tr>
<th>{{'item.edit.metadata.headers.field' | translate}}</th> <th><span id="fieldName">{{'item.edit.metadata.headers.field' | translate}}</span></th>
<th>{{'item.edit.metadata.headers.value' | translate}}</th> <th><span id="fieldValue">{{'item.edit.metadata.headers.value' | translate}}</span></th>
<th class="text-center">{{'item.edit.metadata.headers.language' | translate}}</th> <th class="text-center"><span id="fieldLang">{{'item.edit.metadata.headers.language' | translate}}</span></th>
<th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th> <th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
</tr> </tr>
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate" <tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"

View File

@@ -10,7 +10,7 @@
<ds-dso-page-edit-button [pageRoute]="itemPageRoute$ | async" [dso]="item" [tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button> <ds-dso-page-edit-button [pageRoute]="itemPageRoute$ | async" [dso]="item" [tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button>
</div> </div>
</div> </div>
<div class="simple-view-link my-3"> <div class="simple-view-link my-3" *ngIf="!fromWfi">
<a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]"> <a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]">
{{"item.page.link.simple" | translate}} {{"item.page.link.simple" | translate}}
</a> </a>
@@ -29,6 +29,11 @@
<ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section> <ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section>
<ds-item-page-collections [item]="item"></ds-item-page-collections> <ds-item-page-collections [item]="item"></ds-item-page-collections>
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions> <ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
<div class="button-row bottom" *ngIf="fromWfi">
<div class="text-right">
<button class="btn btn-outline-secondary mr-1" (click)="back()"><i class="fas fa-arrow-left"></i> {{'item.page.return' | translate}}</button>
</div>
</div>
</div> </div>
</div> </div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error> <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>

View File

@@ -1,4 +1,4 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
import { ItemDataService } from '../../core/data/item-data.service'; import { ItemDataService } from '../../core/data/item-data.service';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
@@ -29,9 +29,7 @@ const mockItem: Item = Object.assign(new Item(), {
] ]
} }
}); });
const routeStub = Object.assign(new ActivatedRouteStub(), {
data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) })
});
const metadataServiceStub = { const metadataServiceStub = {
/* tslint:disable:no-empty */ /* tslint:disable:no-empty */
processRemoteData: () => { processRemoteData: () => {
@@ -44,6 +42,10 @@ describe('FullItemPageComponent', () => {
let fixture: ComponentFixture<FullItemPageComponent>; let fixture: ComponentFixture<FullItemPageComponent>;
let authService: AuthService; let authService: AuthService;
let routeStub: ActivatedRouteStub;
let routeData;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
authService = jasmine.createSpyObj('authService', { authService = jasmine.createSpyObj('authService', {
@@ -51,6 +53,14 @@ describe('FullItemPageComponent', () => {
setRedirectUrl: {} setRedirectUrl: {}
}); });
routeData = {
dso: createSuccessfulRemoteDataObject(mockItem),
};
routeStub = Object.assign(new ActivatedRouteStub(), {
data: observableOf(routeData)
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({ imports: [TranslateModule.forRoot({
loader: { loader: {
@@ -84,4 +94,21 @@ describe('FullItemPageComponent', () => {
expect(table.nativeElement.innerHTML).toContain(metadatum.value); expect(table.nativeElement.innerHTML).toContain(metadatum.value);
} }
}); });
it('should show simple view button when not originated from workflow item', () => {
expect(comp.fromWfi).toBe(false);
const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link'));
expect(simpleViewBtn).toBeTruthy();
});
it('should not show simple view button when originated from workflow', fakeAsync(() => {
routeData.wfi = createSuccessfulRemoteDataObject$({ id: 'wfiId'});
comp.ngOnInit();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(comp.fromWfi).toBe(true);
const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link'));
expect(simpleViewBtn).toBeFalsy();
});
}));
}); });

View File

@@ -1,6 +1,6 @@
import {filter, map} from 'rxjs/operators'; import { filter, map } from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Data, Router } from '@angular/router';
import { Observable , BehaviorSubject } from 'rxjs'; import { Observable , BehaviorSubject } from 'rxjs';
@@ -11,11 +11,11 @@ import { ItemDataService } from '../../core/data/item-data.service';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade'; import { fadeInOut } from '../../shared/animations/fade';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { Location } from '@angular/common';
/** /**
* This component renders a full item page. * This component renders a full item page.
@@ -29,14 +29,25 @@ import { AuthService } from '../../core/auth/auth.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeInOut] animations: [fadeInOut]
}) })
export class FullItemPageComponent extends ItemPageComponent implements OnInit { export class FullItemPageComponent extends ItemPageComponent implements OnInit, OnDestroy {
itemRD$: BehaviorSubject<RemoteData<Item>>; itemRD$: BehaviorSubject<RemoteData<Item>>;
metadata$: Observable<MetadataMap>; metadata$: Observable<MetadataMap>;
constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService, authService: AuthService) { /**
super(route, router, items, metadataService, authService); * True when the itemRD has been originated from its workflowitem, false otherwise.
*/
fromWfi = false;
subs = [];
constructor(protected route: ActivatedRoute,
router: Router,
items: ItemDataService,
authService: AuthService,
private _location: Location) {
super(route, router, items, authService);
} }
/*** AoT inheritance fix, will hopefully be resolved in the near future **/ /*** AoT inheritance fix, will hopefully be resolved in the near future **/
@@ -46,5 +57,21 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit {
map((rd: RemoteData<Item>) => rd.payload), map((rd: RemoteData<Item>) => rd.payload),
filter((item: Item) => hasValue(item)), filter((item: Item) => hasValue(item)),
map((item: Item) => item.metadata),); map((item: Item) => item.metadata),);
this.subs.push(this.route.data.subscribe((data: Data) => {
this.fromWfi = hasValue(data.wfi);
})
);
}
/**
* Navigate back in browser history.
*/
back() {
this._location.back();
}
ngOnDestroy() {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
} }
} }

View File

@@ -8,8 +8,6 @@ import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade'; import { fadeInOut } from '../../shared/animations/fade';
import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators'; import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators';
import { ViewMode } from '../../core/shared/view-mode.model'; import { ViewMode } from '../../core/shared/view-mode.model';
@@ -51,10 +49,9 @@ export class ItemPageComponent implements OnInit {
itemPageRoute$: Observable<string>; itemPageRoute$: Observable<string>;
constructor( constructor(
private route: ActivatedRoute, protected route: ActivatedRoute,
private router: Router, private router: Router,
private items: ItemDataService, private items: ItemDataService,
private metadataService: MetadataService,
private authService: AuthService, private authService: AuthService,
) { } ) { }
@@ -66,7 +63,6 @@ export class ItemPageComponent implements OnInit {
map((data) => data.dso as RemoteData<Item>), map((data) => data.dso as RemoteData<Item>),
redirectOn4xx(this.router, this.authService) redirectOn4xx(this.router, this.authService)
); );
this.metadataService.processRemoteData(this.itemRD$);
this.itemPageRoute$ = this.itemRD$.pipe( this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(), getAllSucceededRemoteDataPayload(),
map((item) => getItemPageRoute(item)) map((item) => getItemPageRoute(item))

View File

@@ -0,0 +1,36 @@
import { first } from 'rxjs/operators';
import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { ItemFromWorkflowResolver } from './item-from-workflow.resolver';
describe('ItemFromWorkflowResolver', () => {
describe('resolve', () => {
let resolver: ItemFromWorkflowResolver;
let wfiService: WorkflowItemDataService;
const uuid = '1234-65487-12354-1235';
const itemUuid = '8888-8888-8888-8888';
const wfi = {
id: uuid,
item: createSuccessfulRemoteDataObject$({ id: itemUuid })
};
beforeEach(() => {
wfiService = {
findById: (id: string) => createSuccessfulRemoteDataObject$(wfi)
} as any;
resolver = new ItemFromWorkflowResolver(wfiService, null);
});
it('should resolve a an item from from the workflow item with the correct id', (done) => {
resolver.resolve({ params: { id: uuid } } as any, undefined)
.pipe(first())
.subscribe(
(resolved) => {
expect(resolved.payload.id).toEqual(itemUuid);
done();
}
);
});
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { RemoteData } from '../core/data/remote-data';
import { Item } from '../core/shared/item.model';
import { followLink } from '../shared/utils/follow-link-config.model';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { Store } from '@ngrx/store';
import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service';
import { WorkflowItem } from '../core/submission/models/workflowitem.model';
import { switchMap } from 'rxjs/operators';
/**
* This class represents a resolver that requests a specific item before the route is activated
*/
@Injectable()
export class ItemFromWorkflowResolver implements Resolve<RemoteData<Item>> {
constructor(
private workflowItemService: WorkflowItemDataService,
protected store: Store<any>
) {
}
/**
* Method for resolving an item based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns Observable<<RemoteData<Item>> Emits the found item based on the parameters in the current route,
* or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Item>> {
const itemRD$ = this.workflowItemService.findById(route.params.id,
true,
false,
followLink('item'),
).pipe(
getFirstCompletedRemoteData(),
switchMap((wfiRD: RemoteData<WorkflowItem>) => wfiRD.payload.item as Observable<RemoteData<Item>>),
getFirstCompletedRemoteData()
);
return itemRD$;
}
}

View File

@@ -8,6 +8,9 @@ export function getWorkflowItemPageRoute(wfiId: string) {
export function getWorkflowItemEditRoute(wfiId: string) { export function getWorkflowItemEditRoute(wfiId: string) {
return new URLCombiner(getWorkflowItemModuleRoute(), wfiId, WORKFLOW_ITEM_EDIT_PATH).toString(); return new URLCombiner(getWorkflowItemModuleRoute(), wfiId, WORKFLOW_ITEM_EDIT_PATH).toString();
} }
export function getWorkflowItemViewRoute(wfiId: string) {
return new URLCombiner(getWorkflowItemModuleRoute(), wfiId, WORKFLOW_ITEM_VIEW_PATH).toString();
}
export function getWorkflowItemDeleteRoute(wfiId: string) { export function getWorkflowItemDeleteRoute(wfiId: string) {
return new URLCombiner(getWorkflowItemModuleRoute(), wfiId, WORKFLOW_ITEM_DELETE_PATH).toString(); return new URLCombiner(getWorkflowItemModuleRoute(), wfiId, WORKFLOW_ITEM_DELETE_PATH).toString();
@@ -19,4 +22,5 @@ export function getWorkflowItemSendBackRoute(wfiId: string) {
export const WORKFLOW_ITEM_EDIT_PATH = 'edit'; export const WORKFLOW_ITEM_EDIT_PATH = 'edit';
export const WORKFLOW_ITEM_DELETE_PATH = 'delete'; export const WORKFLOW_ITEM_DELETE_PATH = 'delete';
export const WORKFLOW_ITEM_VIEW_PATH = 'view';
export const WORKFLOW_ITEM_SEND_BACK_PATH = 'sendback'; export const WORKFLOW_ITEM_SEND_BACK_PATH = 'sendback';

View File

@@ -6,12 +6,15 @@ import { WorkflowItemPageResolver } from './workflow-item-page.resolver';
import { import {
WORKFLOW_ITEM_DELETE_PATH, WORKFLOW_ITEM_DELETE_PATH,
WORKFLOW_ITEM_EDIT_PATH, WORKFLOW_ITEM_EDIT_PATH,
WORKFLOW_ITEM_SEND_BACK_PATH WORKFLOW_ITEM_SEND_BACK_PATH,
WORKFLOW_ITEM_VIEW_PATH
} from './workflowitems-edit-page-routing-paths'; } from './workflowitems-edit-page-routing-paths';
import { ThemedSubmissionEditComponent } from '../submission/edit/themed-submission-edit.component'; import { ThemedSubmissionEditComponent } from '../submission/edit/themed-submission-edit.component';
import { ThemedWorkflowItemDeleteComponent } from './workflow-item-delete/themed-workflow-item-delete.component'; import { ThemedWorkflowItemDeleteComponent } from './workflow-item-delete/themed-workflow-item-delete.component';
import { ThemedWorkflowItemSendBackComponent } from './workflow-item-send-back/themed-workflow-item-send-back.component'; import { ThemedWorkflowItemSendBackComponent } from './workflow-item-send-back/themed-workflow-item-send-back.component';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ItemFromWorkflowResolver } from './item-from-workflow.resolver';
import { ThemedFullItemPageComponent } from '../+item-page/full/themed-full-item-page.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -29,6 +32,16 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
}, },
data: { title: 'workflow-item.edit.title', breadcrumbKey: 'workflow-item.edit' } data: { title: 'workflow-item.edit.title', breadcrumbKey: 'workflow-item.edit' }
}, },
{
canActivate: [AuthenticatedGuard],
path: WORKFLOW_ITEM_VIEW_PATH,
component: ThemedFullItemPageComponent,
resolve: {
dso: ItemFromWorkflowResolver,
breadcrumb: I18nBreadcrumbResolver
},
data: { title: 'workflow-item.view.title', breadcrumbKey: 'workflow-item.view' }
},
{ {
canActivate: [AuthenticatedGuard], canActivate: [AuthenticatedGuard],
path: WORKFLOW_ITEM_DELETE_PATH, path: WORKFLOW_ITEM_DELETE_PATH,
@@ -51,7 +64,7 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
}] }]
) )
], ],
providers: [WorkflowItemPageResolver] providers: [WorkflowItemPageResolver, ItemFromWorkflowResolver]
}) })
/** /**
* This module defines the default component to load when navigating to the workflowitems edit page path. * This module defines the default component to load when navigating to the workflowitems edit page path.

View File

@@ -7,6 +7,8 @@ import { WorkflowItemDeleteComponent } from './workflow-item-delete/workflow-ite
import { WorkflowItemSendBackComponent } from './workflow-item-send-back/workflow-item-send-back.component'; import { WorkflowItemSendBackComponent } from './workflow-item-send-back/workflow-item-send-back.component';
import { ThemedWorkflowItemDeleteComponent } from './workflow-item-delete/themed-workflow-item-delete.component'; import { ThemedWorkflowItemDeleteComponent } from './workflow-item-delete/themed-workflow-item-delete.component';
import { ThemedWorkflowItemSendBackComponent } from './workflow-item-send-back/themed-workflow-item-send-back.component'; import { ThemedWorkflowItemSendBackComponent } from './workflow-item-send-back/themed-workflow-item-send-back.component';
import { StatisticsModule } from '../statistics/statistics.module';
import { ItemPageModule } from '../+item-page/item-page.module';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -14,8 +16,15 @@ import { ThemedWorkflowItemSendBackComponent } from './workflow-item-send-back/t
CommonModule, CommonModule,
SharedModule, SharedModule,
SubmissionModule, SubmissionModule,
StatisticsModule,
ItemPageModule
], ],
declarations: [WorkflowItemDeleteComponent, ThemedWorkflowItemDeleteComponent, WorkflowItemSendBackComponent, ThemedWorkflowItemSendBackComponent] declarations: [
WorkflowItemDeleteComponent,
ThemedWorkflowItemDeleteComponent,
WorkflowItemSendBackComponent,
ThemedWorkflowItemSendBackComponent
]
}) })
/** /**
* This module handles all modules that need to access the workflowitems edit page. * This module handles all modules that need to access the workflowitems edit page.

View File

@@ -1,4 +1,4 @@
import { delay, distinctUntilChanged, filter, take } from 'rxjs/operators'; import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -38,6 +38,8 @@ import { ThemeService } from './shared/theme-support/theme.service';
import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; import { BASE_THEME_NAME } from './shared/theme-support/theme.constants';
import { DEFAULT_THEME_CONFIG } from './shared/theme-support/theme.effects'; import { DEFAULT_THEME_CONFIG } from './shared/theme-support/theme.effects';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
@Component({ @Component({
selector: 'ds-app', selector: 'ds-app',
@@ -70,6 +72,11 @@ export class AppComponent implements OnInit, AfterViewInit {
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false); isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
/**
* Whether or not the idle modal is is currently open
*/
idleModalOpen: boolean;
constructor( constructor(
@Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(NativeWindowService) private _window: NativeWindowRef,
@Inject(DOCUMENT) private document: any, @Inject(DOCUMENT) private document: any,
@@ -87,6 +94,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private windowService: HostWindowService, private windowService: HostWindowService,
private localeService: LocaleService, private localeService: LocaleService,
private breadcrumbsService: BreadcrumbsService, private breadcrumbsService: BreadcrumbsService,
private modalService: NgbModal,
@Optional() private cookiesService: KlaroService, @Optional() private cookiesService: KlaroService,
@Optional() private googleAnalyticsService: GoogleAnalyticsService, @Optional() private googleAnalyticsService: GoogleAnalyticsService,
) { ) {
@@ -108,6 +116,11 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
}); });
if (isPlatformBrowser(this.platformId)) {
this.authService.trackTokenExpiration();
this.trackIdleModal();
}
// Load all the languages that are defined as active from the config file // Load all the languages that are defined as active from the config file
translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
@@ -130,7 +143,6 @@ export class AppComponent implements OnInit, AfterViewInit {
console.info(environment); console.info(environment);
} }
this.storeCSSVariables(); this.storeCSSVariables();
} }
ngOnInit() { ngOnInit() {
@@ -229,4 +241,23 @@ export class AppComponent implements OnInit, AfterViewInit {
}; };
head.appendChild(link); head.appendChild(link);
} }
private trackIdleModal() {
const isIdle$ = this.authService.isUserIdle();
const isAuthenticated$ = this.authService.isAuthenticated();
isIdle$.pipe(withLatestFrom(isAuthenticated$))
.subscribe(([userIdle, authenticated]) => {
if (userIdle && authenticated) {
if (!this.idleModalOpen) {
const modalRef = this.modalService.open(IdleModalComponent, { ariaLabelledBy: 'idle-modal.header' });
this.idleModalOpen = true;
modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => {
if (closed) {
this.idleModalOpen = false;
}
});
}
}
});
}
} }

View File

@@ -47,6 +47,7 @@ import { ThemedHeaderComponent } from './header/themed-header.component';
import { ThemedFooterComponent } from './footer/themed-footer.component'; import { ThemedFooterComponent } from './footer/themed-footer.component';
import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component'; import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component';
import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component'; import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
export function getBase() { export function getBase() {
return environment.ui.nameSpace; return environment.ui.nameSpace;
@@ -144,6 +145,7 @@ const DECLARATIONS = [
ThemedBreadcrumbsComponent, ThemedBreadcrumbsComponent,
ForbiddenComponent, ForbiddenComponent,
ThemedForbiddenComponent, ThemedForbiddenComponent,
IdleModalComponent
]; ];
const EXPORTS = [ const EXPORTS = [

View File

@@ -34,7 +34,9 @@ export const AuthActionTypes = {
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'), RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'), RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'), RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'),
REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS') REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS'),
SET_USER_AS_IDLE: type('dspace/auth/SET_USER_AS_IDLE'),
UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE')
}; };
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@@ -404,6 +406,24 @@ export class RetrieveAuthenticatedEpersonErrorAction implements Action {
this.payload = payload ; this.payload = payload ;
} }
} }
/**
* Set the current user as being idle.
* @class SetUserAsIdleAction
* @implements {Action}
*/
export class SetUserAsIdleAction implements Action {
public type: string = AuthActionTypes.SET_USER_AS_IDLE;
}
/**
* Unset the current user as being idle.
* @class UnsetUserAsIdleAction
* @implements {Action}
*/
export class UnsetUserAsIdleAction implements Action {
public type: string = AuthActionTypes.UNSET_USER_AS_IDLE;
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
/** /**
@@ -434,4 +454,7 @@ export type AuthActions
| RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonErrorAction
| RetrieveAuthenticatedEpersonSuccessAction | RetrieveAuthenticatedEpersonSuccessAction
| SetRedirectUrlAction | SetRedirectUrlAction
| RedirectAfterLoginSuccessAction; | RedirectAfterLoginSuccessAction
| SetUserAsIdleAction
| UnsetUserAsIdleAction;

View File

@@ -1,7 +1,13 @@
import { Injectable } from '@angular/core'; import { Injectable, NgZone } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import {
import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; combineLatest as observableCombineLatest,
Observable,
of as observableOf,
timer,
asyncScheduler, queueScheduler
} from 'rxjs';
import { catchError, filter, map, switchMap, take, tap, observeOn } from 'rxjs/operators';
// import @ngrx // import @ngrx
import { Actions, Effect, ofType } from '@ngrx/effects'; import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action, select, Store } from '@ngrx/store'; import { Action, select, Store } from '@ngrx/store';
@@ -37,9 +43,19 @@ import {
RetrieveAuthMethodsAction, RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction, RetrieveAuthMethodsSuccessAction,
RetrieveTokenAction RetrieveTokenAction, SetUserAsIdleAction
} from './auth.actions'; } from './auth.actions';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { environment } from '../../../environments/environment';
import { RequestActionTypes } from '../data/request.actions';
import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions';
import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler';
import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler';
// Action Types that do not break/prevent the user from an idle state
const IDLE_TIMER_IGNORE_TYPES: string[]
= [...Object.values(AuthActionTypes).filter((t: string) => t !== AuthActionTypes.UNSET_USER_AS_IDLE),
...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)];
@Injectable() @Injectable()
export class AuthEffects { export class AuthEffects {
@@ -242,13 +258,35 @@ export class AuthEffects {
}) })
); );
/**
* For any action that is not in {@link IDLE_TIMER_IGNORE_TYPES} that comes in => Start the idleness timer
* If the idleness timer runs out (so no un-ignored action come through for that amount of time)
* => Return the action to set the user as idle ({@link SetUserAsIdleAction})
* @method trackIdleness
*/
@Effect()
public trackIdleness$: Observable<Action> = this.actions$.pipe(
filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)),
// Using switchMap the effect will stop subscribing to the previous timer if a new action comes
// in, and start a new timer
switchMap(() =>
// Start a timer outside of Angular's zone
timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler))
),
// Re-enter the zone to dispatch the action
observeOn(new EnterZoneScheduler(this.zone, queueScheduler)),
map(() => new SetUserAsIdleAction()),
);
/** /**
* @constructor * @constructor
* @param {Actions} actions$ * @param {Actions} actions$
* @param {NgZone} zone
* @param {AuthService} authService * @param {AuthService} authService
* @param {Store} store * @param {Store} store
*/ */
constructor(private actions$: Actions, constructor(private actions$: Actions,
private zone: NgZone,
private authService: AuthService, private authService: AuthService,
private store: Store<AppState>) { private store: Store<AppState>) {
} }

View File

@@ -1,6 +1,6 @@
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
import { catchError, filter, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { Injectable, Injector } from '@angular/core'; import { Injectable, Injector } from '@angular/core';
import { import {
HttpErrorResponse, HttpErrorResponse,
@@ -12,14 +12,13 @@ import {
HttpResponse, HttpResponse,
HttpResponseBase HttpResponseBase
} from '@angular/common/http'; } from '@angular/common/http';
import { find } from 'lodash';
import { AppState } from '../../app.reducer'; import { AppState } from '../../app.reducer';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthTokenInfo } from './models/auth-token-info.model';
import { hasValue, isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util';
import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { RedirectWhenTokenExpiredAction } from './auth.actions';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
@@ -28,7 +27,7 @@ import { AuthMethodType } from './models/auth.method-type';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
// Intercetor is called twice per request, // Interceptor is called twice per request,
// so to prevent RefreshTokenAction is dispatched twice // so to prevent RefreshTokenAction is dispatched twice
// we're creating a refresh token request list // we're creating a refresh token request list
protected refreshTokenRequestUrls = []; protected refreshTokenRequestUrls = [];
@@ -216,23 +215,8 @@ export class AuthInterceptor implements HttpInterceptor {
let authorization: string; let authorization: string;
if (authService.isTokenExpired()) { if (authService.isTokenExpired()) {
authService.setRedirectUrl(this.router.url);
// The access token is expired
// Redirect to the login route
this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired'));
return observableOf(null); return observableOf(null);
} else if ((!this.isAuthRequest(req) || this.isLogoutResponse(req)) && isNotEmpty(token)) { } else if ((!this.isAuthRequest(req) || this.isLogoutResponse(req)) && isNotEmpty(token)) {
// Intercept a request that is not to the authentication endpoint
authService.isTokenExpiring().pipe(
filter((isExpiring) => isExpiring))
.subscribe(() => {
// If the current request url is already in the refresh token request list, skip it
if (isUndefined(find(this.refreshTokenRequestUrls, req.url))) {
// When a token is about to expire, refresh it
this.store.dispatch(new RefreshTokenAction(token));
this.refreshTokenRequestUrls.push(req.url);
}
});
// Get the auth header from the service. // Get the auth header from the service.
authorization = authService.buildAuthHeader(token); authorization = authService.buildAuthHeader(token);
let newHeaders = req.headers.set('authorization', authorization); let newHeaders = req.headers.set('authorization', authorization);

View File

@@ -23,7 +23,7 @@ import {
RetrieveAuthMethodsAction, RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction, RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction SetRedirectUrlAction, SetUserAsIdleAction, UnsetUserAsIdleAction
} from './auth.actions'; } from './auth.actions';
import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthTokenInfo } from './models/auth-token-info.model';
import { EPersonMock } from '../../shared/testing/eperson.mock'; import { EPersonMock } from '../../shared/testing/eperson.mock';
@@ -44,6 +44,7 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: false, loading: false,
idle: false
}; };
const action = new AuthenticateAction('user', 'password'); const action = new AuthenticateAction('user', 'password');
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -53,7 +54,8 @@ describe('authReducer', () => {
blocking: true, blocking: true,
error: undefined, error: undefined,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
@@ -66,7 +68,8 @@ describe('authReducer', () => {
error: undefined, error: undefined,
blocking: true, blocking: true,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
const action = new AuthenticationSuccessAction(mockTokenInfo); const action = new AuthenticationSuccessAction(mockTokenInfo);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -81,7 +84,8 @@ describe('authReducer', () => {
error: undefined, error: undefined,
blocking: true, blocking: true,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
const action = new AuthenticationErrorAction(mockError); const action = new AuthenticationErrorAction(mockError);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -92,7 +96,8 @@ describe('authReducer', () => {
loading: false, loading: false,
info: undefined, info: undefined,
authToken: undefined, authToken: undefined,
error: 'Test error message' error: 'Test error message',
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
@@ -105,7 +110,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
error: undefined, error: undefined,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
const action = new AuthenticatedAction(mockTokenInfo); const action = new AuthenticatedAction(mockTokenInfo);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -115,7 +121,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
error: undefined, error: undefined,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -127,7 +134,8 @@ describe('authReducer', () => {
error: undefined, error: undefined,
blocking: true, blocking: true,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href); const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -138,7 +146,8 @@ describe('authReducer', () => {
error: undefined, error: undefined,
blocking: true, blocking: true,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -150,7 +159,8 @@ describe('authReducer', () => {
error: undefined, error: undefined,
blocking: true, blocking: true,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
const action = new AuthenticatedErrorAction(mockError); const action = new AuthenticatedErrorAction(mockError);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -161,7 +171,8 @@ describe('authReducer', () => {
loaded: true, loaded: true,
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined info: undefined,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -172,6 +183,7 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false, loading: false,
idle: false
}; };
const action = new CheckAuthenticationTokenAction(); const action = new CheckAuthenticationTokenAction();
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -180,6 +192,7 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: true, loading: true,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -190,6 +203,7 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: true, loading: true,
idle: false
}; };
const action = new CheckAuthenticationTokenCookieAction(); const action = new CheckAuthenticationTokenCookieAction();
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -198,6 +212,7 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: true, loading: true,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -211,7 +226,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
const action = new LogOutAction(); const action = new LogOutAction();
@@ -229,7 +245,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
const action = new LogOutSuccessAction(); const action = new LogOutSuccessAction();
@@ -243,7 +260,8 @@ describe('authReducer', () => {
loading: true, loading: true,
info: undefined, info: undefined,
refreshing: false, refreshing: false,
userId: undefined userId: undefined,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -257,7 +275,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
const action = new LogOutErrorAction(mockError); const action = new LogOutErrorAction(mockError);
@@ -270,7 +289,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -283,7 +303,8 @@ describe('authReducer', () => {
error: undefined, error: undefined,
blocking: true, blocking: true,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id); const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -295,7 +316,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -307,7 +329,8 @@ describe('authReducer', () => {
error: undefined, error: undefined,
blocking: true, blocking: true,
loading: true, loading: true,
info: undefined info: undefined,
idle: false
}; };
const action = new RetrieveAuthenticatedEpersonErrorAction(mockError); const action = new RetrieveAuthenticatedEpersonErrorAction(mockError);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -318,7 +341,8 @@ describe('authReducer', () => {
loaded: true, loaded: true,
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined info: undefined,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -332,7 +356,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
const newTokenInfo = new AuthTokenInfo('Refreshed token'); const newTokenInfo = new AuthTokenInfo('Refreshed token');
const action = new RefreshTokenAction(newTokenInfo); const action = new RefreshTokenAction(newTokenInfo);
@@ -346,7 +371,8 @@ describe('authReducer', () => {
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id, userId: EPersonMock.id,
refreshing: true refreshing: true,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -361,7 +387,8 @@ describe('authReducer', () => {
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id, userId: EPersonMock.id,
refreshing: true refreshing: true,
idle: false
}; };
const newTokenInfo = new AuthTokenInfo('Refreshed token'); const newTokenInfo = new AuthTokenInfo('Refreshed token');
const action = new RefreshTokenSuccessAction(newTokenInfo); const action = new RefreshTokenSuccessAction(newTokenInfo);
@@ -375,7 +402,8 @@ describe('authReducer', () => {
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id, userId: EPersonMock.id,
refreshing: false refreshing: false,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -390,7 +418,8 @@ describe('authReducer', () => {
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id, userId: EPersonMock.id,
refreshing: true refreshing: true,
idle: false
}; };
const action = new RefreshTokenErrorAction(); const action = new RefreshTokenErrorAction();
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -403,7 +432,8 @@ describe('authReducer', () => {
loading: false, loading: false,
info: undefined, info: undefined,
refreshing: false, refreshing: false,
userId: undefined userId: undefined,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -417,7 +447,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
state = { state = {
@@ -428,7 +459,8 @@ describe('authReducer', () => {
loading: false, loading: false,
error: undefined, error: undefined,
info: 'Message', info: 'Message',
userId: undefined userId: undefined,
idle: false
}; };
}); });
@@ -450,6 +482,7 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false, loading: false,
idle: false
}; };
const action = new AddAuthenticationMessageAction('Message'); const action = new AddAuthenticationMessageAction('Message');
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -458,7 +491,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false, loading: false,
info: 'Message' info: 'Message',
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -470,7 +504,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
error: 'Error', error: 'Error',
info: 'Message' info: 'Message',
idle: false
}; };
const action = new ResetAuthenticationMessagesAction(); const action = new ResetAuthenticationMessagesAction();
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -480,7 +515,8 @@ describe('authReducer', () => {
blocking: false, blocking: false,
loading: false, loading: false,
error: undefined, error: undefined,
info: undefined info: undefined,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -490,7 +526,8 @@ describe('authReducer', () => {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false loading: false,
idle: false
}; };
const action = new SetRedirectUrlAction('redirect.url'); const action = new SetRedirectUrlAction('redirect.url');
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -499,7 +536,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false, loading: false,
redirectUrl: 'redirect.url' redirectUrl: 'redirect.url',
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -510,7 +548,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false, loading: false,
authMethods: [] authMethods: [],
idle: false
}; };
const action = new RetrieveAuthMethodsAction(new AuthStatus(), true); const action = new RetrieveAuthMethodsAction(new AuthStatus(), true);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -519,7 +558,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: true, loading: true,
authMethods: [] authMethods: [],
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -530,7 +570,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: true, loading: true,
authMethods: [] authMethods: [],
idle: false
}; };
const authMethods = [ const authMethods = [
new AuthMethod(AuthMethodType.Password), new AuthMethod(AuthMethodType.Password),
@@ -543,7 +584,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false, loading: false,
authMethods: authMethods authMethods: authMethods,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -554,7 +596,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: true, loading: true,
authMethods: [] authMethods: [],
idle: false
}; };
const authMethods = [ const authMethods = [
new AuthMethod(AuthMethodType.Password), new AuthMethod(AuthMethodType.Password),
@@ -567,7 +610,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: false, loading: false,
authMethods: authMethods authMethods: authMethods,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -578,7 +622,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: true, loading: true,
authMethods: [] authMethods: [],
idle: false
}; };
const action = new RetrieveAuthMethodsErrorAction(false); const action = new RetrieveAuthMethodsErrorAction(false);
@@ -588,7 +633,50 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false, loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)] authMethods: [new AuthMethod(AuthMethodType.Password)],
idle: false
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a SET_USER_AS_IDLE action', () => {
initialState = {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
idle: false
};
const action = new SetUserAsIdleAction();
const newState = authReducer(initialState, action);
state = {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
idle: true
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a UNSET_USER_AS_IDLE action', () => {
initialState = {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
idle: true
};
const action = new UnsetUserAsIdleAction();
const newState = authReducer(initialState, action);
state = {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -599,7 +687,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: true, loading: true,
authMethods: [] authMethods: [],
idle: false
}; };
const action = new RetrieveAuthMethodsErrorAction(true); const action = new RetrieveAuthMethodsErrorAction(true);
@@ -609,7 +698,8 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: false, loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)] authMethods: [new AuthMethod(AuthMethodType.Password)],
idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });

View File

@@ -59,6 +59,9 @@ export interface AuthState {
// all authentication Methods enabled at the backend // all authentication Methods enabled at the backend
authMethods?: AuthMethod[]; authMethods?: AuthMethod[];
// true when the current user is idle
idle: boolean;
} }
/** /**
@@ -69,7 +72,8 @@ const initialState: AuthState = {
loaded: false, loaded: false,
blocking: true, blocking: true,
loading: false, loading: false,
authMethods: [] authMethods: [],
idle: false
}; };
/** /**
@@ -189,6 +193,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
return Object.assign({}, state, { return Object.assign({}, state, {
authToken: (action as RefreshTokenSuccessAction).payload, authToken: (action as RefreshTokenSuccessAction).payload,
refreshing: false, refreshing: false,
blocking: false
}); });
case AuthActionTypes.ADD_MESSAGE: case AuthActionTypes.ADD_MESSAGE:
@@ -234,6 +239,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
blocking: true, blocking: true,
}); });
case AuthActionTypes.SET_USER_AS_IDLE:
return Object.assign({}, state, {
idle: true,
});
case AuthActionTypes.UNSET_USER_AS_IDLE:
return Object.assign({}, state, {
idle: false,
});
default: default:
return state; return state;
} }

View File

@@ -27,6 +27,11 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util
import { authMethodsMock } from '../../shared/testing/auth-service.stub'; import { authMethodsMock } from '../../shared/testing/auth-service.stub';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service'; import { HardRedirectService } from '../services/hard-redirect.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions';
describe('AuthService test', () => { describe('AuthService test', () => {
@@ -47,6 +52,7 @@ describe('AuthService test', () => {
let token: AuthTokenInfo; let token: AuthTokenInfo;
let authenticatedState; let authenticatedState;
let unAuthenticatedState; let unAuthenticatedState;
let idleState;
let linkService; let linkService;
let hardRedirectService; let hardRedirectService;
@@ -64,14 +70,24 @@ describe('AuthService test', () => {
loaded: true, loaded: true,
loading: false, loading: false,
authToken: token, authToken: token,
user: EPersonMock user: EPersonMock,
idle: false
}; };
unAuthenticatedState = { unAuthenticatedState = {
authenticated: false, authenticated: false,
loaded: true, loaded: true,
loading: false, loading: false,
authToken: undefined, authToken: undefined,
user: undefined user: undefined,
idle: false
};
idleState = {
authenticated: true,
loaded: true,
loading: false,
authToken: token,
user: EPersonMock,
idle: true
}; };
authRequest = new AuthRequestServiceStub(); authRequest = new AuthRequestServiceStub();
routeStub = new ActivatedRouteStub(); routeStub = new ActivatedRouteStub();
@@ -107,6 +123,8 @@ describe('AuthService test', () => {
{ provide: Store, useValue: mockStore }, { provide: Store, useValue: mockStore },
{ provide: EPersonDataService, useValue: mockEpersonDataService }, { provide: EPersonDataService, useValue: mockEpersonDataService },
{ provide: HardRedirectService, useValue: hardRedirectService }, { provide: HardRedirectService, useValue: hardRedirectService },
{ provide: NotificationsService, useValue: NotificationsServiceStub },
{ provide: TranslateService, useValue: getMockTranslateService() },
CookieService, CookieService,
AuthService AuthService
], ],
@@ -180,6 +198,26 @@ describe('AuthService test', () => {
expect(authMethods.length).toBe(2); expect(authMethods.length).toBe(2);
}); });
}); });
describe('setIdle true', () => {
beforeEach(() => {
authService.setIdle(true);
});
it('store should dispatch SetUserAsIdleAction', () => {
expect(mockStore.dispatch).toHaveBeenCalledWith(new SetUserAsIdleAction());
});
});
describe('setIdle false', () => {
beforeEach(() => {
authService.setIdle(false);
});
it('store should dispatch UnsetUserAsIdleAction', () => {
expect(mockStore.dispatch).toHaveBeenCalledWith(new UnsetUserAsIdleAction());
});
});
}); });
describe('', () => { describe('', () => {
@@ -207,13 +245,13 @@ describe('AuthService test', () => {
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService) => { beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
store store
.subscribe((state) => { .subscribe((state) => {
(state as any).core = Object.create({}); (state as any).core = Object.create({});
(state as any).core.auth = authenticatedState; (state as any).core.auth = authenticatedState;
}); });
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
})); }));
it('should return true when user is logged in', () => { it('should return true when user is logged in', () => {
@@ -250,6 +288,12 @@ describe('AuthService test', () => {
}); });
}); });
it('isUserIdle should return false when user is not yet idle', () => {
authService.isUserIdle().subscribe((status: boolean) => {
expect(status).toBe(false);
});
});
}); });
describe('', () => { describe('', () => {
@@ -277,7 +321,7 @@ describe('AuthService test', () => {
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService) => { beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
const expiredToken: AuthTokenInfo = new AuthTokenInfo('test_token'); const expiredToken: AuthTokenInfo = new AuthTokenInfo('test_token');
expiredToken.expires = Date.now() - (1000 * 60 * 60); expiredToken.expires = Date.now() - (1000 * 60 * 60);
authenticatedState = { authenticatedState = {
@@ -292,7 +336,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({}); (state as any).core = Object.create({});
(state as any).core.auth = authenticatedState; (state as any).core.auth = authenticatedState;
}); });
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
storage = (authService as any).storage; storage = (authService as any).storage;
routeServiceMock = TestBed.inject(RouteService); routeServiceMock = TestBed.inject(RouteService);
routerStub = TestBed.inject(Router); routerStub = TestBed.inject(Router);
@@ -493,13 +537,13 @@ describe('AuthService test', () => {
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService) => { beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
store store
.subscribe((state) => { .subscribe((state) => {
(state as any).core = Object.create({}); (state as any).core = Object.create({});
(state as any).core.auth = unAuthenticatedState; (state as any).core.auth = unAuthenticatedState;
}); });
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
})); }));
it('should return null for the shortlived token', () => { it('should return null for the shortlived token', () => {
@@ -508,4 +552,44 @@ describe('AuthService test', () => {
}); });
}); });
}); });
describe('when user is idle', () => {
beforeEach(waitForAsync(() => {
init();
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({ authReducer }, {
runtimeChecks: {
strictStateImmutability: false,
strictActionImmutability: false
}
})
],
providers: [
{ provide: AuthRequestService, useValue: authRequest },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: RemoteDataBuildService, useValue: linkService },
CookieService,
AuthService
]
}).compileComponents();
}));
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = idleState;
});
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
}));
it('isUserIdle should return true when user is not idle', () => {
authService.isUserIdle().subscribe((status: boolean) => {
expect(status).toBe(true);
});
});
});
}); });

View File

@@ -29,14 +29,17 @@ import {
getRedirectUrl, getRedirectUrl,
isAuthenticated, isAuthenticated,
isAuthenticatedLoaded, isAuthenticatedLoaded,
isIdle,
isTokenRefreshing isTokenRefreshing
} from './selectors'; } from './selectors';
import { AppState } from '../../app.reducer'; import { AppState } from '../../app.reducer';
import { import {
CheckAuthenticationTokenAction, CheckAuthenticationTokenAction, RefreshTokenAction,
ResetAuthenticationMessagesAction, ResetAuthenticationMessagesAction,
RetrieveAuthMethodsAction, RetrieveAuthMethodsAction,
SetRedirectUrlAction SetRedirectUrlAction,
SetUserAsIdleAction,
UnsetUserAsIdleAction
} from './auth.actions'; } from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
@@ -46,6 +49,9 @@ import { getAllSucceededRemoteDataPayload } from '../shared/operators';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service'; import { HardRedirectService } from '../services/hard-redirect.service';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { environment } from '../../../environments/environment';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
export const LOGIN_ROUTE = '/login'; export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout'; export const LOGOUT_ROUTE = '/logout';
@@ -64,6 +70,11 @@ export class AuthService {
*/ */
protected _authenticated: boolean; protected _authenticated: boolean;
/**
* Timer to track time until token refresh
*/
private tokenRefreshTimer;
constructor(@Inject(REQUEST) protected req: any, constructor(@Inject(REQUEST) protected req: any,
@Inject(NativeWindowService) protected _window: NativeWindowRef, @Inject(NativeWindowService) protected _window: NativeWindowRef,
@Optional() @Inject(RESPONSE) private response: any, @Optional() @Inject(RESPONSE) private response: any,
@@ -73,7 +84,9 @@ export class AuthService {
protected routeService: RouteService, protected routeService: RouteService,
protected storage: CookieService, protected storage: CookieService,
protected store: Store<AppState>, protected store: Store<AppState>,
protected hardRedirectService: HardRedirectService protected hardRedirectService: HardRedirectService,
private notificationService: NotificationsService,
private translateService: TranslateService
) { ) {
this.store.pipe( this.store.pipe(
select(isAuthenticated), select(isAuthenticated),
@@ -187,7 +200,7 @@ export class AuthService {
return this.store.pipe( return this.store.pipe(
select(getAuthenticatedUserId), select(getAuthenticatedUserId),
hasValueOperator(), hasValueOperator(),
switchMap((id: string) => this.epersonService.findById(id) ), switchMap((id: string) => this.epersonService.findById(id)),
getAllSucceededRemoteDataPayload() getAllSucceededRemoteDataPayload()
); );
} }
@@ -298,7 +311,7 @@ export class AuthService {
*/ */
public getToken(): AuthTokenInfo { public getToken(): AuthTokenInfo {
let token: AuthTokenInfo; let token: AuthTokenInfo;
this.store.pipe(select(getAuthenticationToken)) this.store.pipe(take(1), select(getAuthenticationToken))
.subscribe((authTokenInfo: AuthTokenInfo) => { .subscribe((authTokenInfo: AuthTokenInfo) => {
// Retrieve authentication token info and check if is valid // Retrieve authentication token info and check if is valid
token = authTokenInfo || null; token = authTokenInfo || null;
@@ -306,6 +319,44 @@ export class AuthService {
return token; return token;
} }
/**
* Method that checks when the session token from store expires and refreshes it when needed
*/
public trackTokenExpiration(): void {
let token: AuthTokenInfo;
let currentlyRefreshingToken = false;
this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => {
// If new token is undefined an it wasn't previously => Refresh failed
if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) {
// Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out
this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed'));
setTimeout(() => this.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()), 10000);
currentlyRefreshingToken = false;
}
// If new token.expires is different => Refresh succeeded
if (currentlyRefreshingToken && authTokenInfo !== undefined && token.expires !== authTokenInfo.expires) {
currentlyRefreshingToken = false;
}
// Check if/when token needs to be refreshed
if (!currentlyRefreshingToken) {
token = authTokenInfo || null;
if (token !== undefined && token !== null) {
let timeLeftBeforeRefresh = token.expires - new Date().getTime() - environment.auth.rest.timeLeftBeforeTokenRefresh;
if (timeLeftBeforeRefresh < 0) {
timeLeftBeforeRefresh = 0;
}
if (hasValue(this.tokenRefreshTimer)) {
clearTimeout(this.tokenRefreshTimer);
}
this.tokenRefreshTimer = setTimeout(() => {
this.store.dispatch(new RefreshTokenAction(token));
currentlyRefreshingToken = true;
}, timeLeftBeforeRefresh);
}
}
});
}
/** /**
* Check if a token is next to be expired * Check if a token is next to be expired
* @returns {boolean} * @returns {boolean}
@@ -346,7 +397,7 @@ export class AuthService {
// Set the cookie expire date // Set the cookie expire date
const expires = new Date(expireDate); const expires = new Date(expireDate);
const options: CookieAttributes = { expires: expires }; const options: CookieAttributes = {expires: expires};
// Save cookie with the token // Save cookie with the token
return this.storage.set(TOKENITEM, token, options); return this.storage.set(TOKENITEM, token, options);
@@ -396,11 +447,14 @@ export class AuthService {
* @param redirectUrl * @param redirectUrl
*/ */
public navigateToRedirectUrl(redirectUrl: string) { public navigateToRedirectUrl(redirectUrl: string) {
let url = `/reload/${new Date().getTime()}`; // Don't do redirect if already on reload url
if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { if (!hasValue(redirectUrl) || !redirectUrl.includes('/reload/')) {
url += `?redirect=${encodeURIComponent(redirectUrl)}`; let url = `/reload/${new Date().getTime()}`;
if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
url += `?redirect=${encodeURIComponent(redirectUrl)}`;
}
this.hardRedirectService.redirect(url);
} }
this.hardRedirectService.redirect(url);
} }
/** /**
@@ -435,7 +489,7 @@ export class AuthService {
// Set the cookie expire date // Set the cookie expire date
const expires = new Date(expireDate); const expires = new Date(expireDate);
const options: CookieAttributes = { expires: expires }; const options: CookieAttributes = {expires: expires};
this.storage.set(REDIRECT_COOKIE, url, options); this.storage.set(REDIRECT_COOKIE, url, options);
this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : ''));
} }
@@ -528,4 +582,24 @@ export class AuthService {
return new RetrieveAuthMethodsAction(authStatus, false); return new RetrieveAuthMethodsAction(authStatus, false);
} }
/**
* Determines if current user is idle
* @returns {Observable<boolean>}
*/
public isUserIdle(): Observable<boolean> {
return this.store.pipe(select(isIdle));
}
/**
* Set idle of auth state
* @returns {Observable<boolean>}
*/
public setIdle(idle: boolean): void {
if (idle) {
this.store.dispatch(new SetUserAsIdleAction());
} else {
this.store.dispatch(new UnsetUserAsIdleAction());
}
}
} }

View File

@@ -115,6 +115,14 @@ const _getRedirectUrl = (state: AuthState) => state.redirectUrl;
const _getAuthenticationMethods = (state: AuthState) => state.authMethods; const _getAuthenticationMethods = (state: AuthState) => state.authMethods;
/**
* Returns true if the user is idle.
* @function _isIdle
* @param {State} state
* @returns {boolean}
*/
const _isIdle = (state: AuthState) => state.idle;
/** /**
* Returns the authentication methods enabled at the backend * Returns the authentication methods enabled at the backend
* @function getAuthenticationMethods * @function getAuthenticationMethods
@@ -231,3 +239,12 @@ export const getRegistrationError = createSelector(getAuthState, _getRegistratio
* @return {string} * @return {string}
*/ */
export const getRedirectUrl = createSelector(getAuthState, _getRedirectUrl); export const getRedirectUrl = createSelector(getAuthState, _getRedirectUrl);
/**
* Returns true if the user is idle
* @function isIdle
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isIdle = createSelector(getAuthState, _isIdle);

View File

@@ -13,6 +13,7 @@ import {
BitstreamFormatRegistryState BitstreamFormatRegistryState
} from '../+admin/admin-registries/bitstream-formats/bitstream-format.reducers'; } from '../+admin/admin-registries/bitstream-formats/bitstream-format.reducers';
import { historyReducer, HistoryState } from './history/history.reducer'; import { historyReducer, HistoryState } from './history/history.reducer';
import { metaTagReducer, MetaTagState } from './metadata/meta-tag.reducer';
export interface CoreState { export interface CoreState {
'bitstreamFormats': BitstreamFormatRegistryState; 'bitstreamFormats': BitstreamFormatRegistryState;
@@ -24,6 +25,7 @@ export interface CoreState {
'index': MetaIndexState; 'index': MetaIndexState;
'auth': AuthState; 'auth': AuthState;
'json/patch': JsonPatchOperationsState; 'json/patch': JsonPatchOperationsState;
'metaTag': MetaTagState;
'route': RouteState; 'route': RouteState;
} }
@@ -37,5 +39,6 @@ export const coreReducers: ActionReducerMap<CoreState> = {
'index': indexReducer, 'index': indexReducer,
'auth': authReducer, 'auth': authReducer,
'json/patch': jsonPatchOperationsReducer, 'json/patch': jsonPatchOperationsReducer,
'metaTag': metaTagReducer,
'route': routeReducer 'route': routeReducer
}; };

View File

@@ -0,0 +1,23 @@
import { type } from '../../shared/ngrx/type';
import { Action } from '@ngrx/store';
// tslint:disable:max-classes-per-file
export const MetaTagTypes = {
ADD: type('dspace/meta-tag/ADD'),
CLEAR: type('dspace/meta-tag/CLEAR')
};
export class AddMetaTagAction implements Action {
type = MetaTagTypes.ADD;
payload: string;
constructor(property: string) {
this.payload = property;
}
}
export class ClearMetaTagAction implements Action {
type = MetaTagTypes.CLEAR;
}
export type MetaTagAction = AddMetaTagAction | ClearMetaTagAction;

View File

@@ -0,0 +1,50 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { metaTagReducer } from './meta-tag.reducer';
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
const nullAction = { type: null };
describe('metaTagReducer', () => {
it('should start with an empty array', () => {
const state0 = metaTagReducer(undefined, nullAction);
expect(state0.tagsInUse).toEqual([]);
});
it('should return the current state on invalid action', () => {
const state0 = {
tagsInUse: ['foo', 'bar'],
};
const state1 = metaTagReducer(state0, nullAction);
expect(state1).toEqual(state0);
});
it('should add tags on AddMetaTagAction', () => {
const state0 = {
tagsInUse: ['foo'],
};
const state1 = metaTagReducer(state0, new AddMetaTagAction('bar'));
const state2 = metaTagReducer(state1, new AddMetaTagAction('baz'));
expect(state1.tagsInUse).toEqual(['foo', 'bar']);
expect(state2.tagsInUse).toEqual(['foo', 'bar', 'baz']);
});
it('should clear tags on ClearMetaTagAction', () => {
const state0 = {
tagsInUse: ['foo', 'bar'],
};
const state1 = metaTagReducer(state0, new ClearMetaTagAction());
expect(state1.tagsInUse).toEqual([]);
});
});

View File

@@ -0,0 +1,38 @@
import {
MetaTagAction,
MetaTagTypes,
AddMetaTagAction,
ClearMetaTagAction,
} from './meta-tag.actions';
export interface MetaTagState {
tagsInUse: string[];
}
const initialstate: MetaTagState = {
tagsInUse: []
};
export const metaTagReducer = (state: MetaTagState = initialstate, action: MetaTagAction): MetaTagState => {
switch (action.type) {
case MetaTagTypes.ADD: {
return addMetaTag(state, action as AddMetaTagAction);
}
case MetaTagTypes.CLEAR: {
return clearMetaTags(state, action as ClearMetaTagAction);
}
default: {
return state;
}
}
};
const addMetaTag = (state: MetaTagState, action: AddMetaTagAction): MetaTagState => {
return {
tagsInUse: [...state.tagsInUse, action.payload]
};
};
const clearMetaTags = (state: MetaTagState, action: ClearMetaTagAction): MetaTagState => {
return Object.assign({}, initialstate);
};

View File

@@ -1,82 +1,28 @@
import { CommonModule, Location } from '@angular/common'; import { fakeAsync, tick } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http'; import { Meta, Title } from '@angular/platform-browser';
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Store, StoreModule } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { EmptyError, Observable, of } from 'rxjs';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { import { ItemMock, MockBitstream1, MockBitstream3 } from '../../shared/mocks/item.mock';
ItemMock, import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
MockBitstream1, import { PaginatedList } from '../data/paginated-list.model';
MockBitstream2,
MockBitstreamFormat1,
MockBitstreamFormat2
} from '../../shared/mocks/item.mock';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { AuthService } from '../auth/auth.service';
import { BrowseService } from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
import { BitstreamDataService } from '../data/bitstream-data.service';
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
import { CommunityDataService } from '../data/community-data.service';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { ItemDataService } from '../data/item-data.service';
import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model';
import { FindListOptions } from '../data/request.models';
import { RequestService } from '../data/request.service';
import { BitstreamFormat } from '../shared/bitstream-format.model';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { MetadataValue } from '../shared/metadata.models'; import { MetadataValue } from '../shared/metadata.models';
import { PageInfo } from '../shared/page-info.model';
import { UUIDService } from '../shared/uuid.service';
import { MetadataService } from './metadata.service'; import { MetadataService } from './metadata.service';
import { environment } from '../../../environments/environment';
import { storeModuleConfig } from '../../app.reducer';
import { HardRedirectService } from '../services/hard-redirect.service';
import { URLCombiner } from '../url-combiner/url-combiner';
import { RootDataService } from '../data/root-data.service'; import { RootDataService } from '../data/root-data.service';
import { Root } from '../data/root.model'; import { Bundle } from '../shared/bundle.model';
import { createPaginatedList } from '../../shared/testing/utils.test';
/* tslint:disable:max-classes-per-file */ import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
@Component({ import { DSONameService } from '../breadcrumbs/dso-name.service';
template: ` import { HardRedirectService } from '../services/hard-redirect.service';
<router-outlet></router-outlet>` import { getMockStore } from '@ngrx/store/testing';
}) import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
class TestComponent {
constructor(private metadata: MetadataService) {
metadata.listenForRouteChange();
}
}
@Component({ template: '' })
class DummyItemComponent {
constructor(private route: ActivatedRoute, private items: ItemDataService, private metadata: MetadataService) {
this.route.params.subscribe((params) => {
this.metadata.processRemoteData(this.items.findById(params.id));
});
}
}
/* tslint:enable:max-classes-per-file */
describe('MetadataService', () => { describe('MetadataService', () => {
let metadataService: MetadataService; let metadataService: MetadataService;
@@ -85,188 +31,339 @@ describe('MetadataService', () => {
let title: Title; let title: Title;
let store: Store<CoreState>; let dsoNameService: DSONameService;
let objectCacheService: ObjectCacheService; let bundleDataService;
let requestService: RequestService; let bitstreamDataService;
let uuidService: UUIDService;
let remoteDataBuildService: RemoteDataBuildService;
let itemDataService: ItemDataService;
let authService: AuthService;
let rootService: RootDataService; let rootService: RootDataService;
let translateService: TranslateService; let translateService: TranslateService;
let hardRedirectService: HardRedirectService;
let location: Location;
let router: Router; let router: Router;
let fixture: ComponentFixture<TestComponent>; let store;
const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}};
let tagStore: Map<string, MetaDefinition[]>;
beforeEach(() => { beforeEach(() => {
rootService = jasmine.createSpyObj({
findRoot: createSuccessfulRemoteDataObject$({ dspaceVersion: 'mock-dspace-version' })
});
bitstreamDataService = jasmine.createSpyObj({
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([MockBitstream3]))
});
bundleDataService = jasmine.createSpyObj({
findByItemAndName: mockBundleRD$([MockBitstream3])
});
translateService = getMockTranslateService();
meta = jasmine.createSpyObj('meta', {
addTag: {},
removeTag: {}
});
title = jasmine.createSpyObj({
setTitle: {}
});
dsoNameService = jasmine.createSpyObj({
getName: ItemMock.firstMetadataValue('dc.title')
});
router = {
url: '/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
events: of(new NavigationEnd(1, '', '')),
routerState: {
root: {}
}
} as any as Router;
hardRedirectService = jasmine.createSpyObj( {
getRequestOrigin: 'https://request.org',
});
store = new Store<CoreState>(undefined, undefined, undefined); // @ts-ignore
store = getMockStore({ initialState });
spyOn(store, 'dispatch'); spyOn(store, 'dispatch');
objectCacheService = new ObjectCacheService(store, undefined); metadataService = new MetadataService(
uuidService = new UUIDService(); router,
requestService = new RequestService(objectCacheService, uuidService, store, undefined); translateService,
remoteDataBuildService = new RemoteDataBuildService(objectCacheService, undefined, requestService); meta,
const mockBitstreamDataService = { title,
findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<Bitstream>[]): Observable<RemoteData<PaginatedList<Bitstream>>> { dsoNameService,
if (item.equals(ItemMock)) { bundleDataService,
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [MockBitstream1, MockBitstream2])); bitstreamDataService,
} else { undefined,
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); rootService,
} store,
}, hardRedirectService
}; );
const mockBitstreamFormatDataService = {
findByBitstream(bitstream: Bitstream): Observable<RemoteData<BitstreamFormat>> {
switch (bitstream) {
case MockBitstream1:
return createSuccessfulRemoteDataObject$(MockBitstreamFormat1);
break;
case MockBitstream2:
return createSuccessfulRemoteDataObject$(MockBitstreamFormat2);
break;
default:
return createSuccessfulRemoteDataObject$(new BitstreamFormat());
}
}
};
rootService = jasmine.createSpyObj('rootService', {
findRoot: createSuccessfulRemoteDataObject$(Object.assign(new Root(), {
dspaceVersion: 'mock-dspace-version'
}))
});
TestBed.configureTestingModule({
imports: [
CommonModule,
StoreModule.forRoot({}, storeModuleConfig),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
}),
RouterTestingModule.withRoutes([
{ path: 'items/:id', component: DummyItemComponent, pathMatch: 'full' },
{
path: 'other',
component: DummyItemComponent,
pathMatch: 'full',
data: { title: 'Dummy Title', description: 'This is a dummy item component for testing!' }
}
])
],
declarations: [
TestComponent,
DummyItemComponent
],
providers: [
{ provide: ObjectCacheService, useValue: objectCacheService },
{ provide: RequestService, useValue: requestService },
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService },
{ provide: HALEndpointService, useValue: {} },
{ provide: AuthService, useValue: {} },
{ provide: NotificationsService, useValue: {} },
{ provide: HttpClient, useValue: {} },
{ provide: DSOChangeAnalyzer, useValue: {} },
{ provide: CommunityDataService, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: BitstreamFormatDataService, useValue: mockBitstreamFormatDataService },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: RootDataService, useValue: rootService },
Meta,
Title,
// tslint:disable-next-line:no-empty
{ provide: ItemDataService, useValue: { findById: () => {} } },
BrowseService,
MetadataService
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
});
meta = TestBed.inject(Meta);
title = TestBed.inject(Title);
itemDataService = TestBed.inject(ItemDataService);
metadataService = TestBed.inject(MetadataService);
authService = TestBed.inject(AuthService);
translateService = TestBed.inject(TranslateService);
router = TestBed.inject(Router);
location = TestBed.inject(Location);
fixture = TestBed.createComponent(TestComponent);
tagStore = metadataService.getTagStore();
}); });
it('items page should set meta tags', fakeAsync(() => { it('items page should set meta tags', fakeAsync(() => {
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); (metadataService as any).processRouteChange({
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
}
});
tick(); tick();
expect(title.getTitle()).toEqual('Test PowerPoint Document'); expect(title.setTitle).toHaveBeenCalledWith('Test PowerPoint Document');
expect(tagStore.get('citation_title')[0].content).toEqual('Test PowerPoint Document'); expect(meta.addTag).toHaveBeenCalledWith({
expect(tagStore.get('citation_author')[0].content).toEqual('Doe, Jane'); property: 'citation_title',
expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26'); content: 'Test PowerPoint Document'
expect(tagStore.get('citation_issn')[0].content).toEqual('123456789'); });
expect(tagStore.get('citation_language')[0].content).toEqual('en'); expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_author', content: 'Doe, Jane' });
expect(tagStore.get('citation_keywords')[0].content).toEqual('keyword1; keyword2; keyword3'); expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_publication_date',
content: '1650-06-26'
});
expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_issn', content: '123456789' });
expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_language', content: 'en' });
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_keywords',
content: 'keyword1; keyword2; keyword3'
});
})); }));
it('items page should set meta tags as published Thesis', fakeAsync(() => { it('items page should set meta tags as published Thesis', fakeAsync(() => {
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Thesis')))); (metadataService as any).processRouteChange({
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
}
}
});
tick(); tick();
expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document'); expect(meta.addTag).toHaveBeenCalledWith({
expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher'); property: 'citation_dissertation_name',
expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join('')); content: 'Test PowerPoint Document'
expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'); });
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
});
})); }));
it('items page should set meta tags as published Technical Report', fakeAsync(() => { it('items page should set meta tags as published Technical Report', fakeAsync(() => {
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Technical Report')))); (metadataService as any).processRouteChange({
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); data: {
tick(); value: {
expect(tagStore.get('citation_technical_report_institution')[0].content).toEqual('Mock Publisher'); dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
})); }
}
it('other navigation should add title, description and Generator', fakeAsync(() => {
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock));
spyOn(translateService, 'get').and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!'));
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
tick();
expect(tagStore.size).toBeGreaterThan(0);
router.navigate(['/other']);
tick();
expect(tagStore.size).toEqual(3);
expect(title.getTitle()).toEqual('DSpace :: Dummy Title');
expect(tagStore.get('title')[0].content).toEqual('DSpace :: Dummy Title');
expect(tagStore.get('description')[0].content).toEqual('This is a dummy item component for testing!');
expect(tagStore.get('Generator')[0].content).toEqual('mock-dspace-version');
}));
describe('when the item has no bitstreams', () => {
beforeEach(() => {
// this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL')
// spyOn(MockItem, 'getFiles').and.returnValue(observableOf([]));
}); });
tick();
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_technical_report_institution',
content: 'Mock Publisher'
});
}));
it('processRemoteData should not produce an EmptyError', fakeAsync(() => { it('other navigation should add title and description', fakeAsync(() => {
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!'));
spyOn(metadataService, 'processRemoteData').and.callThrough(); (metadataService as any).processRouteChange({
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); data: {
value: {
title: 'Dummy Title',
description: 'This is a dummy item component for testing!'
}
}
});
tick();
expect(title.setTitle).toHaveBeenCalledWith('DSpace :: Dummy Title');
expect(meta.addTag).toHaveBeenCalledWith({
property: 'title',
content: 'DSpace :: Dummy Title'
});
expect(meta.addTag).toHaveBeenCalledWith({
property: 'description',
content: 'This is a dummy item component for testing!'
});
}));
describe(`listenForRouteChange`, () => {
it(`should call processRouteChange`, fakeAsync(() => {
spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined);
metadataService.listenForRouteChange();
tick(); tick();
expect(metadataService.processRemoteData).not.toThrow(new EmptyError()); expect((metadataService as any).processRouteChange).toHaveBeenCalled();
}));
it(`should add Generator`, fakeAsync(() => {
spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined);
metadataService.listenForRouteChange();
tick();
expect(meta.addTag).toHaveBeenCalledWith({
property: 'Generator',
content: 'mock-dspace-version'
});
})); }));
}); });
const mockRemoteData = (mockItem: Item): Observable<RemoteData<Item>> => { describe('citation_abstract_html_url', () => {
return createSuccessfulRemoteDataObject$(ItemMock); it('should use dc.identifier.uri if available', fakeAsync(() => {
}; (metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockUri(ItemMock, 'https://ddg.gg')),
}
}
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_abstract_html_url',
content: 'https://ddg.gg'
});
}));
it('should use current route as fallback', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockUri(ItemMock)),
}
}
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_abstract_html_url',
content: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'
});
}));
});
describe('citation_*_institution / citation_publisher', () => {
it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
}
}
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_dissertation_institution',
content: 'Mock Publisher'
});
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_technical_report_institution' }));
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_publisher' }));
}));
it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
}
}
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_dissertation_institution' }));
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_technical_report_institution',
content: 'Mock Publisher'
});
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_publisher' }));
}));
it('should use citation_publisher for other item types', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Some Other Type'))),
}
}
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_dissertation_institution' }));
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_technical_report_institution' }));
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_publisher',
content: 'Mock Publisher'
});
}));
});
describe('citation_pdf_url', () => {
it('should link to primary Bitstream URL regardless of format', fakeAsync(() => {
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([], MockBitstream3));
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
}
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
});
}));
describe('no primary Bitstream', () => {
it('should link to first and only Bitstream regardless of format', fakeAsync(() => {
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3]));
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
}
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_pdf_url',
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
});
}));
it('should link to first Bitstream with allowed format', fakeAsync(() => {
const bitstreams = [MockBitstream3, MockBitstream3, MockBitstream1];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
(bitstreamDataService.findAllByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
}
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
property: 'citation_pdf_url',
content: 'https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'
});
}));
});
});
describe('tagstore', () => {
beforeEach(fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
}
});
tick();
}));
it('should remove previous tags on route change', fakeAsync(() => {
expect(meta.removeTag).toHaveBeenCalledWith('property=\'title\'');
expect(meta.removeTag).toHaveBeenCalledWith('property=\'description\'');
}));
it('should clear all tags and add new ones on route change', () => {
expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]);
expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]);
expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]);
});
});
const mockType = (mockItem: Item, type: string): Item => { const mockType = (mockItem: Item, type: string): Item => {
const typedMockItem = Object.assign(new Item(), mockItem) as Item; const typedMockItem = Object.assign(new Item(), mockItem) as Item;
@@ -285,4 +382,30 @@ describe('MetadataService', () => {
return publishedMockItem; return publishedMockItem;
}; };
const mockUri = (mockItem: Item, uri?: string): Item => {
const publishedMockItem = Object.assign(new Item(), mockItem) as Item;
publishedMockItem.metadata['dc.identifier.uri'] = [{ value: uri }] as MetadataValue[];
return publishedMockItem;
};
const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable<RemoteData<Bundle>> => {
return createSuccessfulRemoteDataObject$(
Object.assign(new Bundle(), {
name: 'ORIGINAL',
bitstreams: createSuccessfulRemoteDataObject$(mockBitstreamPages$(bitstreams)[0]),
primaryBitstream: createSuccessfulRemoteDataObject$(primary),
})
);
};
const mockBitstreamPages$ = (bitstreams: Bitstream[]): PaginatedList<Bitstream>[] => {
return bitstreams.map((bitstream, index) => Object.assign(createPaginatedList([bitstream]), {
pageInfo: {
totalElements: bitstreams.length, // announce multiple elements/pages
},
_links: index < bitstreams.length - 1
? { next: { href: 'not empty' }} // fake link to the next bitstream page
: { next: { href: undefined }}, // last page has no link
}));
};
}); });

View File

@@ -5,12 +5,11 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, EMPTY, Observable, of as observableOf } from 'rxjs';
import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'rxjs/operators'; import { expand, filter, map, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { hasNoValue, hasValue } from '../../shared/empty.util';
import { DSONameService } from '../breadcrumbs/dso-name.service'; import { DSONameService } from '../breadcrumbs/dso-name.service';
import { CacheableObject } from '../cache/object-cache.reducer';
import { BitstreamDataService } from '../data/bitstream-data.service'; import { BitstreamDataService } from '../data/bitstream-data.service';
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
@@ -19,22 +18,57 @@ import { BitstreamFormat } from '../shared/bitstream-format.model';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators';
getFirstSucceededRemoteDataPayload,
getFirstSucceededRemoteListPayload
} from '../shared/operators';
import { environment } from '../../../environments/environment';
import { RootDataService } from '../data/root-data.service'; import { RootDataService } from '../data/root-data.service';
import { getBitstreamDownloadRoute } from '../../app-routing-paths'; import { getBitstreamDownloadRoute } from '../../app-routing-paths';
import { BundleDataService } from '../data/bundle-data.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { Bundle } from '../shared/bundle.model';
import { PaginatedList } from '../data/paginated-list.model';
import { URLCombiner } from '../url-combiner/url-combiner';
import { HardRedirectService } from '../services/hard-redirect.service';
import { MetaTagState } from './meta-tag.reducer';
import { createSelector, select, Store } from '@ngrx/store';
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
import { coreSelector } from '../core.selectors';
import { CoreState } from '../core.reducers';
/**
* The base selector function to select the metaTag section in the store
*/
const metaTagSelector = createSelector(
coreSelector,
(state: CoreState) => state.metaTag
);
/**
* Selector function to select the tags in use from the MetaTagState
*/
const tagsInUseSelector =
createSelector(
metaTagSelector,
(state: MetaTagState) => state.tagsInUse,
);
@Injectable() @Injectable()
export class MetadataService { export class MetadataService {
private initialized: boolean; private currentObject: BehaviorSubject<DSpaceObject> = new BehaviorSubject<DSpaceObject>(undefined);
private tagStore: Map<string, MetaDefinition[]>; /**
* When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream),
private currentObject: BehaviorSubject<DSpaceObject>; * the first Bitstream to match one of the following MIME types is selected.
* See {@linkcode getFirstAllowedFormatBitstreamLink}
* @private
*/
private readonly CITATION_PDF_URL_MIMETYPES = [
'application/pdf', // .pdf
'application/postscript', // .ps
'application/msword', // .doc
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/rtf', // .rtf
'application/epub+zip', // .epub
];
constructor( constructor(
private router: Router, private router: Router,
@@ -42,21 +76,19 @@ export class MetadataService {
private meta: Meta, private meta: Meta,
private title: Title, private title: Title,
private dsoNameService: DSONameService, private dsoNameService: DSONameService,
private bundleDataService: BundleDataService,
private bitstreamDataService: BitstreamDataService, private bitstreamDataService: BitstreamDataService,
private bitstreamFormatDataService: BitstreamFormatDataService, private bitstreamFormatDataService: BitstreamFormatDataService,
private rootService: RootDataService private rootService: RootDataService,
private store: Store<CoreState>,
private hardRedirectService: HardRedirectService,
) { ) {
// TODO: determine what open graph meta tags are needed and whether
// the differ per route. potentially add image based on DSpaceObject
this.meta.addTags([
{ property: 'og:title', content: 'DSpace Angular Universal' },
{ property: 'og:description', content: 'The modern front-end for DSpace 7.' }
]);
this.initialized = false;
this.tagStore = new Map<string, MetaDefinition[]>();
} }
public listenForRouteChange(): void { public listenForRouteChange(): void {
// This never changes, set it only once
this.setGenerator();
this.router.events.pipe( this.router.events.pipe(
filter((event) => event instanceof NavigationEnd), filter((event) => event instanceof NavigationEnd),
map(() => this.router.routerState.root), map(() => this.router.routerState.root),
@@ -68,22 +100,9 @@ export class MetadataService {
}); });
} }
public processRemoteData(remoteData: Observable<RemoteData<CacheableObject>>): void {
remoteData.pipe(map((rd: RemoteData<CacheableObject>) => rd.payload),
filter((co: CacheableObject) => hasValue(co)),
take(1))
.subscribe((dspaceObject: DSpaceObject) => {
if (!this.initialized) {
this.initialize(dspaceObject);
}
this.currentObject.next(dspaceObject);
});
}
private processRouteChange(routeInfo: any): void { private processRouteChange(routeInfo: any): void {
if (routeInfo.params.value.id === undefined) { this.clearMetaTags();
this.clearMetaTags();
}
if (routeInfo.data.value.title) { if (routeInfo.data.value.title) {
const titlePrefix = this.translate.get('repository.title.prefix'); const titlePrefix = this.translate.get('repository.title.prefix');
const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value); const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value);
@@ -98,15 +117,10 @@ export class MetadataService {
}); });
} }
this.setGenerator(); if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) {
} this.currentObject.next(routeInfo.data.value.dso.payload);
this.setDSOMetaTags();
private initialize(dspaceObject: DSpaceObject): void { }
this.currentObject = new BehaviorSubject<DSpaceObject>(dspaceObject);
this.currentObject.asObservable().pipe(distinctUntilKeyChanged('uuid')).subscribe(() => {
this.setMetaTags();
});
this.initialized = true;
} }
private getCurrentRoute(route: ActivatedRoute): ActivatedRoute { private getCurrentRoute(route: ActivatedRoute): ActivatedRoute {
@@ -116,16 +130,14 @@ export class MetadataService {
return route; return route;
} }
private setMetaTags(): void { private setDSOMetaTags(): void {
this.clearMetaTags();
this.setTitleTag(); this.setTitleTag();
this.setDescriptionTag(); this.setDescriptionTag();
this.setCitationTitleTag(); this.setCitationTitleTag();
this.setCitationAuthorTags(); this.setCitationAuthorTags();
this.setCitationDateTag(); this.setCitationPublicationDateTag();
this.setCitationISSNTag(); this.setCitationISSNTag();
this.setCitationISBNTag(); this.setCitationISBNTag();
@@ -134,14 +146,10 @@ export class MetadataService {
this.setCitationAbstractUrlTag(); this.setCitationAbstractUrlTag();
this.setCitationPdfUrlTag(); this.setCitationPdfUrlTag();
this.setCitationPublisherTag();
if (this.isDissertation()) { if (this.isDissertation()) {
this.setCitationDissertationNameTag(); this.setCitationDissertationNameTag();
this.setCitationDissertationInstitutionTag();
}
if (this.isTechReport()) {
this.setCitationTechReportInstitutionTag();
} }
// this.setCitationJournalTitleTag(); // this.setCitationJournalTitleTag();
@@ -176,7 +184,7 @@ export class MetadataService {
private setDescriptionTag(): void { private setDescriptionTag(): void {
// TODO: truncate abstract // TODO: truncate abstract
const value = this.getMetaTagValue('dc.description.abstract'); const value = this.getMetaTagValue('dc.description.abstract');
this.addMetaTag('desciption', value); this.addMetaTag('description', value);
} }
/** /**
@@ -196,11 +204,11 @@ export class MetadataService {
} }
/** /**
* Add <meta name="citation_date" ... > to the <head> * Add <meta name="citation_publication_date" ... > to the <head>
*/ */
private setCitationDateTag(): void { private setCitationPublicationDateTag(): void {
const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']); const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']);
this.addMetaTag('citation_date', value); this.addMetaTag('citation_publication_date', value);
} }
/** /**
@@ -236,19 +244,17 @@ export class MetadataService {
} }
/** /**
* Add <meta name="citation_dissertation_institution" ... > to the <head> * Add dc.publisher to the <head>. The tag name depends on the item type.
*/ */
private setCitationDissertationInstitutionTag(): void { private setCitationPublisherTag(): void {
const value = this.getMetaTagValue('dc.publisher'); const value = this.getMetaTagValue('dc.publisher');
this.addMetaTag('citation_dissertation_institution', value); if (this.isDissertation()) {
} this.addMetaTag('citation_dissertation_institution', value);
} else if (this.isTechReport()) {
/** this.addMetaTag('citation_technical_report_institution', value);
* Add <meta name="citation_technical_report_institution" ... > to the <head> } else {
*/ this.addMetaTag('citation_publisher', value);
private setCitationTechReportInstitutionTag(): void { }
const value = this.getMetaTagValue('dc.publisher');
this.addMetaTag('citation_technical_report_institution', value);
} }
/** /**
@@ -264,8 +270,11 @@ export class MetadataService {
*/ */
private setCitationAbstractUrlTag(): void { private setCitationAbstractUrlTag(): void {
if (this.currentObject.value instanceof Item) { if (this.currentObject.value instanceof Item) {
const value = [environment.ui.baseUrl, this.router.url].join(''); let url = this.getMetaTagValue('dc.identifier.uri');
this.addMetaTag('citation_abstract_html_url', value); if (hasNoValue(url)) {
url = new URLCombiner(this.hardRedirectService.getRequestOrigin(), this.router.url).toString();
}
this.addMetaTag('citation_abstract_html_url', url);
} }
} }
@@ -275,35 +284,126 @@ export class MetadataService {
private setCitationPdfUrlTag(): void { private setCitationPdfUrlTag(): void {
if (this.currentObject.value instanceof Item) { if (this.currentObject.value instanceof Item) {
const item = this.currentObject.value as Item; const item = this.currentObject.value as Item;
this.bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL')
.pipe( // Retrieve the ORIGINAL bundle for the item
getFirstSucceededRemoteListPayload(), this.bundleDataService.findByItemAndName(
first((files) => isNotEmpty(files)), item,
catchError((error) => { 'ORIGINAL',
console.debug(error.message); true,
return []; true,
})) followLink('primaryBitstream'),
.subscribe((bitstreams: Bitstream[]) => { followLink('bitstreams', {}, followLink('format')),
for (const bitstream of bitstreams) { ).pipe(
this.bitstreamFormatDataService.findByBitstream(bitstream).pipe( getFirstSucceededRemoteDataPayload(),
getFirstSucceededRemoteDataPayload() switchMap((bundle: Bundle) =>
).subscribe((format: BitstreamFormat) => {
if (format.mimetype === 'application/pdf') { // First try the primary bitstream
const bitstreamLink = getBitstreamDownloadRoute(bitstream); bundle.primaryBitstream.pipe(
this.addMetaTag('citation_pdf_url', bitstreamLink); getFirstCompletedRemoteData(),
map((rd: RemoteData<Bitstream>) => {
if (hasValue(rd.payload)) {
return rd.payload;
} else {
return null;
} }
}); }),
// return the bundle as well so we can use it again if there's no primary bitstream
map((bitstream: Bitstream) => [bundle, bitstream])
)
),
switchMap(([bundle, primaryBitstream]: [Bundle, Bitstream]) => {
if (hasValue(primaryBitstream)) {
// If there was a primary bitstream, emit its link
return [getBitstreamDownloadRoute(primaryBitstream)];
} else {
// Otherwise consider the regular bitstreams in the bundle
return bundle.bitstreams.pipe(
getFirstCompletedRemoteData(),
switchMap((bitstreamRd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRd.payload) && bitstreamRd.payload.totalElements === 1) {
// If there's only one bitstream in the bundle, emit its link
return [getBitstreamDownloadRoute(bitstreamRd.payload.page[0])];
} else {
// Otherwise check all bitstreams to see if one matches the format whitelist
return this.getFirstAllowedFormatBitstreamLink(bitstreamRd);
}
})
);
} }
}); }),
take(1)
).subscribe((link: string) => {
// Use the found link to set the <meta> tag
this.addMetaTag(
'citation_pdf_url',
new URLCombiner(this.hardRedirectService.getRequestOrigin(), link).toString()
);
});
} }
} }
/**
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream with a MIME type
* included in {@linkcode CITATION_PDF_URL_MIMETYPES}
* @param bitstreamRd
* @private
*/
private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
return observableOf(bitstreamRd.payload).pipe(
// Because there can be more than one page of bitstreams, this expand operator
// will retrieve them in turn. Due to the take(1) at the bottom, it will only
// retrieve pages until a match is found
expand((paginatedList: PaginatedList<Bitstream>) => {
if (hasNoValue(paginatedList.next)) {
// If there's no next page, stop.
return EMPTY;
} else {
// Otherwise retrieve the next page
return this.bitstreamDataService.findAllByHref(
paginatedList.next,
undefined,
true,
true,
followLink('format')
).pipe(
getFirstCompletedRemoteData(),
map((next: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(next.payload)) {
return next.payload;
} else {
return EMPTY;
}
})
);
}
}),
// Return the array of bitstreams inside each paginated list
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
// Emit the bitstreams in the list one at a time
switchMap((bitstreams: Bitstream[]) => bitstreams),
// Retrieve the format for each bitstream
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
getFirstSucceededRemoteDataPayload(),
// Keep the original bitstream, because it, not the format, is what we'll need
// for the link at the end
map((format: BitstreamFormat) => [bitstream, format])
)),
// Filter out only pairs with whitelisted formats
filter(([, format]: [Bitstream, BitstreamFormat]) =>
hasValue(format) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
// We only need 1
take(1),
// Emit the link of the match
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
);
}
/** /**
* Add <meta name="Generator" ... > to the <head> containing the current DSpace version * Add <meta name="Generator" ... > to the <head> containing the current DSpace version
*/ */
private setGenerator(): void { private setGenerator(): void {
this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => { this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => {
this.addMetaTag('Generator', root.dspaceVersion); this.meta.addTag({ property: 'Generator', content: root.dspaceVersion });
}); });
} }
@@ -351,7 +451,7 @@ export class MetadataService {
if (content) { if (content) {
const tag = { property, content } as MetaDefinition; const tag = { property, content } as MetaDefinition;
this.meta.addTag(tag); this.meta.addTag(tag);
this.storeTag(property, tag); this.storeTag(property);
} }
} }
@@ -361,33 +461,21 @@ export class MetadataService {
} }
} }
private storeTag(key: string, tag: MetaDefinition): void { private storeTag(key: string): void {
const tags: MetaDefinition[] = this.getTags(key); this.store.dispatch(new AddMetaTagAction(key));
tags.push(tag);
this.setTags(key, tags);
}
private getTags(key: string): MetaDefinition[] {
let tags: MetaDefinition[] = this.tagStore.get(key);
if (tags === undefined) {
tags = [];
}
return tags;
}
private setTags(key: string, tags: MetaDefinition[]): void {
this.tagStore.set(key, tags);
} }
public clearMetaTags() { public clearMetaTags() {
this.tagStore.forEach((tags: MetaDefinition[], property: string) => { this.store.pipe(
this.meta.removeTag('property=\'' + property + '\''); select(tagsInUseSelector),
take(1)
).subscribe((tagsInUse: string[]) => {
for (const property of tagsInUse) {
this.meta.removeTag('property=\'' + property + '\'');
}
this.store.dispatch(new ClearMetaTagAction());
}); });
this.tagStore.clear();
} }
public getTagStore(): Map<string, MetaDefinition[]> {
return this.tagStore;
}
} }

View File

@@ -0,0 +1,19 @@
import { SchedulerLike, Subscription } from 'rxjs';
import { NgZone } from '@angular/core';
/**
* An RXJS scheduler that will re-enter the Angular zone to run what's scheduled
*/
export class EnterZoneScheduler implements SchedulerLike {
constructor(private zone: NgZone, private scheduler: SchedulerLike) { }
schedule(...args: any[]): Subscription {
return this.zone.run(() =>
this.scheduler.schedule.apply(this.scheduler, args)
);
}
now (): number {
return this.scheduler.now();
}
}

View File

@@ -0,0 +1,19 @@
import { SchedulerLike, Subscription } from 'rxjs';
import { NgZone } from '@angular/core';
/**
* An RXJS scheduler that will run what's scheduled outside of the Angular zone
*/
export class LeaveZoneScheduler implements SchedulerLike {
constructor(private zone: NgZone, private scheduler: SchedulerLike) { }
schedule(...args: any[]): Subscription {
return this.zone.runOutsideAngular(() =>
this.scheduler.schedule.apply(this.scheduler, args)
);
}
now (): number {
return this.scheduler.now();
}
}

View File

@@ -1,4 +1,5 @@
<li class="nav-item dropdown" <li class="nav-item dropdown"
(keyup.enter)="activateSection($event)"
(mouseenter)="activateSection($event)" (mouseenter)="activateSection($event)"
(mouseleave)="deactivateSection($event)"> (mouseleave)="deactivateSection($event)">
<a href="#" class="nav-link dropdown-toggle" routerLinkActive="active" <a href="#" class="nav-link dropdown-toggle" routerLinkActive="active"

View File

@@ -1,5 +1,5 @@
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Component, Inject, OnInit, Optional, Input } from '@angular/core'; import { Component, Inject, OnInit, Input } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
@@ -18,8 +18,6 @@ import { HostWindowService } from '../shared/host-window.service';
import { ThemeConfig } from '../../config/theme.model'; import { ThemeConfig } from '../../config/theme.model';
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { LocaleService } from '../core/locale/locale.service';
import { KlaroService } from '../shared/cookies/klaro.service';
import { slideSidebarPadding } from '../shared/animations/slide'; import { slideSidebarPadding } from '../shared/animations/slide';
@Component({ @Component({
@@ -58,9 +56,7 @@ export class RootComponent implements OnInit {
private router: Router, private router: Router,
private cssService: CSSVariableService, private cssService: CSSVariableService,
private menuService: MenuService, private menuService: MenuService,
private windowService: HostWindowService, private windowService: HostWindowService
private localeService: LocaleService,
@Optional() private cookiesService: KlaroService
) { ) {
} }

View File

@@ -1,10 +1,10 @@
<div id="search-navbar-container" [title]="'nav.search' | translate" (dsClickOutside)="collapse()"> <div id="search-navbar-container" [title]="'nav.search' | translate" (dsClickOutside)="collapse()">
<div class="d-inline-block position-relative"> <div class="d-inline-block position-relative">
<form [formGroup]="searchForm" (ngSubmit)="onSubmit(searchForm.value)" autocomplete="on"> <form [formGroup]="searchForm" (ngSubmit)="onSubmit(searchForm.value)" autocomplete="on">
<input #searchInput [@toggleAnimation]="isExpanded" id="query" name="query" <input #searchInput [@toggleAnimation]="isExpanded" [attr.aria-label]="('nav.search' | translate)" name="query"
formControlName="query" type="text" placeholder="{{searchExpanded ? ('nav.search' | translate) : ''}}" formControlName="query" type="text" placeholder="{{searchExpanded ? ('nav.search' | translate) : ''}}"
class="d-inline-block bg-transparent position-absolute form-control dropdown-menu-right p-1"> class="d-inline-block bg-transparent position-absolute form-control dropdown-menu-right p-1">
<a class="submit-icon" (click)="searchExpanded ? onSubmit(searchForm.value) : expand()"> <a class="submit-icon" [routerLink]="" (click)="searchExpanded ? onSubmit(searchForm.value) : expand()">
<em class="fas fa-search fa-lg fa-fw"></em> <em class="fas fa-search fa-lg fa-fw"></em>
</a> </a>
</form> </form>

View File

@@ -17,7 +17,7 @@ a.submit-icon {
} }
@media screen and (max-width: map-get($grid-breakpoints, md)) { @media screen and (max-width: map-get($grid-breakpoints, md)) {
#query:focus { .query:focus {
max-width: 250px !important; max-width: 250px !important;
width: 40vw !important; width: 40vw !important;
} }

View File

@@ -2,25 +2,26 @@
<li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item" <li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item"
(click)="$event.stopPropagation();"> (click)="$event.stopPropagation();">
<div ngbDropdown #loginDrop display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut> <div ngbDropdown #loginDrop display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="#" id="dropdownLogin" (click)="$event.preventDefault()" ngbDropdownToggle <a href="#" class="dropdownLogin px-1 " [attr.aria-label]="'nav.login' |translate" (click)="$event.preventDefault()" ngbDropdownToggle>
class="px-1">{{ 'nav.login' | translate }}</a> {{ 'nav.login' | translate }}
<div id="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu </a>
aria-labelledby="dropdownLogin"> <div class="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu
[attr.aria-label]="'nav.login' |translate">
<ds-log-in <ds-log-in
[isStandalonePage]="false"></ds-log-in> [isStandalonePage]="false"></ds-log-in>
</div> </div>
</div> </div>
</li> </li>
<li *ngIf="!(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item"> <li *ngIf="!(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item">
<a id="loginLink" routerLink="/login" routerLinkActive="active" class="px-1"> <a routerLink="/login" routerLinkActive="active" class="loginLink px-1">
{{ 'nav.login' | translate }}<span class="sr-only">(current)</span> {{ 'nav.login' | translate }}<span class="sr-only">(current)</span>
</a> </a>
</li> </li>
<li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item"> <li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item">
<div ngbDropdown display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut> <div ngbDropdown display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="#" id="dropdownUser" role="button" [attr.aria-label]="'nav.logout' |translate" (click)="$event.preventDefault()" [title]="'nav.logout' | translate" class="px-1" ngbDropdownToggle> <a href="#" role="button" [attr.aria-label]="'nav.logout' |translate" (click)="$event.preventDefault()" [title]="'nav.logout' | translate" class="px-1" ngbDropdownToggle>
<i class="fas fa-user-circle fa-lg fa-fw"></i></a> <i class="fas fa-user-circle fa-lg fa-fw"></i></a>
<div id="logoutDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownUser"> <div class="logoutDropdownMenu" ngbDropdownMenu [attr.aria-label]="'nav.logout' |translate">
<ds-user-menu></ds-user-menu> <ds-user-menu></ds-user-menu>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
#loginDropdownMenu, #logoutDropdownMenu { .loginDropdownMenu, .logoutDropdownMenu {
min-width: 330px; min-width: 330px;
z-index: 1002; z-index: 1002;
} }
#loginDropdownMenu { .loginDropdownMenu {
min-height: 260px; min-height: 260px;
} }

View File

@@ -44,7 +44,8 @@ describe('AuthNavMenuComponent', () => {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false loading: false,
idle: false
}; };
authState = { authState = {
authenticated: true, authenticated: true,
@@ -52,7 +53,8 @@ describe('AuthNavMenuComponent', () => {
blocking: false, blocking: false,
loading: false, loading: false,
authToken: new AuthTokenInfo('test_token'), authToken: new AuthTokenInfo('test_token'),
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
} }
@@ -206,7 +208,7 @@ describe('AuthNavMenuComponent', () => {
}); });
it('should render login dropdown menu', () => { it('should render login dropdown menu', () => {
const loginDropdownMenu = deNavMenuItem.query(By.css('div[id=loginDropdownMenu]')); const loginDropdownMenu = deNavMenuItem.query(By.css('div.loginDropdownMenu'));
expect(loginDropdownMenu.nativeElement).toBeDefined(); expect(loginDropdownMenu.nativeElement).toBeDefined();
}); });
}); });
@@ -318,7 +320,7 @@ describe('AuthNavMenuComponent', () => {
}); });
it('should render login link', () => { it('should render login link', () => {
const loginDropdownMenu = deNavMenuItem.query(By.css('a[id=loginLink]')); const loginDropdownMenu = deNavMenuItem.query(By.css('.loginLink'));
expect(loginDropdownMenu.nativeElement).toBeDefined(); expect(loginDropdownMenu.nativeElement).toBeDefined();
}); });
}); });

View File

@@ -37,7 +37,8 @@ describe('UserMenuComponent', () => {
blocking: false, blocking: false,
loading: false, loading: false,
authToken: new AuthTokenInfo('test_token'), authToken: new AuthTokenInfo('test_token'),
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
authStateLoading = { authStateLoading = {
authenticated: true, authenticated: true,
@@ -45,7 +46,8 @@ describe('UserMenuComponent', () => {
blocking: false, blocking: false,
loading: true, loading: true,
authToken: null, authToken: null,
userId: EPersonMock.id userId: EPersonMock.id,
idle: false
}; };
} }

View File

@@ -31,9 +31,9 @@ export function dateToISOFormat(date: Date | NgbDateStruct | string): string {
const dateObj: Date = (date instanceof Date) ? date : const dateObj: Date = (date instanceof Date) ? date :
((typeof date === 'string') ? ngbDateStructToDate(stringToNgbDateStruct(date)) : ngbDateStructToDate(date)); ((typeof date === 'string') ? ngbDateStructToDate(stringToNgbDateStruct(date)) : ngbDateStructToDate(date));
let year = dateObj.getFullYear().toString(); let year = dateObj.getUTCFullYear().toString();
let month = (dateObj.getMonth() + 1).toString(); let month = (dateObj.getUTCMonth() + 1).toString();
let day = dateObj.getDate().toString(); let day = dateObj.getUTCDate().toString();
let hour = dateObj.getHours().toString(); let hour = dateObj.getHours().toString();
let min = dateObj.getMinutes().toString(); let min = dateObj.getMinutes().toString();
let sec = dateObj.getSeconds().toString(); let sec = dateObj.getSeconds().toString();
@@ -57,7 +57,7 @@ export function dateToISOFormat(date: Date | NgbDateStruct | string): string {
* the Date object * the Date object
*/ */
export function ngbDateStructToDate(date: NgbDateStruct): Date { export function ngbDateStructToDate(date: NgbDateStruct): Date {
return new Date(date.year, (date.month - 1), date.day); return new Date(Date.UTC(date.year, (date.month - 1), date.day));
} }
/** /**
@@ -86,9 +86,9 @@ export function dateToNgbDateStruct(date?: Date): NgbDateStruct {
} }
return { return {
year: date.getFullYear(), year: date.getUTCFullYear(),
month: date.getMonth() + 1, month: date.getUTCMonth() + 1,
day: date.getDate() day: date.getUTCDate()
}; };
} }
@@ -103,9 +103,9 @@ export function dateToNgbDateStruct(date?: Date): NgbDateStruct {
export function dateToString(date: Date | NgbDateStruct): string { export function dateToString(date: Date | NgbDateStruct): string {
const dateObj: Date = (date instanceof Date) ? date : ngbDateStructToDate(date); const dateObj: Date = (date instanceof Date) ? date : ngbDateStructToDate(date);
let year = dateObj.getFullYear().toString(); let year = dateObj.getUTCFullYear().toString();
let month = (dateObj.getMonth() + 1).toString(); let month = (dateObj.getUTCMonth() + 1).toString();
let day = dateObj.getDate().toString(); let day = dateObj.getUTCDate().toString();
year = (year.length === 1) ? '0' + year : year; year = (year.length === 1) ? '0' + year : year;
month = (month.length === 1) ? '0' + month : month; month = (month.length === 1) ? '0' + month : month;

View File

@@ -1,5 +1,6 @@
<a [href]="bitstreamPath"><ng-container *ngTemplateOutlet="content"></ng-container></a> <a [href]="bitstreamPath" [target]="isBlank ? '_blank': '_self'" [ngClass]="cssClasses">
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
<ng-template #content> <ng-template #content>
<ng-content></ng-content> <ng-content></ng-content>

View File

@@ -18,6 +18,17 @@ export class FileDownloadLinkComponent implements OnInit {
* Optional bitstream instead of href and file name * Optional bitstream instead of href and file name
*/ */
@Input() bitstream: Bitstream; @Input() bitstream: Bitstream;
/**
* Additional css classes to apply to link
*/
@Input() cssClasses = '';
/**
* A boolean representing if link is shown in same tab or in a new one.
*/
@Input() isBlank = false;
bitstreamPath: string; bitstreamPath: string;
ngOnInit() { ngOnInit() {

View File

@@ -2,7 +2,8 @@
[formGroup]="group" [formGroup]="group"
[ngClass]="[getClass('element', 'container'), getClass('grid', 'container')]"> [ngClass]="[getClass('element', 'container'), getClass('grid', 'container')]">
<label *ngIf="!isCheckbox && hasLabel" <label *ngIf="!isCheckbox && hasLabel"
[for]="model.id" [id]="'label_' + model.id"
[for]="id"
[innerHTML]="(model.required && model.label) ? (model.label | translate) + ' *' : (model.label | translate)" [innerHTML]="(model.required && model.label) ? (model.label | translate) + ' *' : (model.label | translate)"
[ngClass]="[getClass('element', 'label'), getClass('grid', 'label')]"></label> [ngClass]="[getClass('element', 'label'), getClass('grid', 'label')]"></label>
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: model"></ng-container> <ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: model"></ng-container>

View File

@@ -15,7 +15,9 @@
[cdkDragDisabled]="dragDisabled" [cdkDragDisabled]="dragDisabled"
[cdkDragPreviewClass]="'ds-submission-reorder-dragging'"> [cdkDragPreviewClass]="'ds-submission-reorder-dragging'">
<!-- Item content --> <!-- Item content -->
<i class="drag-icon fas fa-grip-vertical fa-fw" [class.invisible]="dragDisabled"></i> <div class="drag-handle" [class.invisible]="dragDisabled" tabindex="0">
<i class="drag-icon fas fa-grip-vertical fa-fw" ></i>
</div>
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container> <ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container>
<ds-dynamic-form-control-container *ngFor="let _model of groupModel.group" <ds-dynamic-form-control-container *ngFor="let _model of groupModel.group"
[bindId]="false" [bindId]="false"

View File

@@ -26,8 +26,20 @@
} }
.drag-handle {
&:hover {
cursor: move;
}
&:focus {
.drag-icon {
visibility: visible;
}
}
}
.cdk-drop-list-dragging { .cdk-drop-list-dragging {
.cdk-drag { .drag-handle {
cursor: grabbing; cursor: grabbing;
.drag-icon { .drag-icon {
visibility: hidden; visibility: hidden;

View File

@@ -1,6 +1,7 @@
<div [formGroup]="group" class="input-group"> <div [formGroup]="group" class="input-group">
<input ngbDatepicker class="form-control" #datepicker="ngbDatepicker" <input ngbDatepicker class="form-control" #datepicker="ngbDatepicker"
[attr.aria-labelledby]="'label_' + model.id"
[class.is-invalid]="showErrorMessages" [class.is-invalid]="showErrorMessages"
[displayMonths]="model.getAdditional('displayMonths', config['displayMonths'])" [displayMonths]="model.getAdditional('displayMonths', config['displayMonths'])"
[id]="id" [id]="id"
@@ -26,6 +27,7 @@
<button class="btn btn-outline-secondary" <button class="btn btn-outline-secondary"
type="button" type="button"
[attr.aria-labelledby]="'label_' + model.id"
[class.disabled]="model.disabled" [class.disabled]="model.disabled"
[disabled]="model.disabled" [disabled]="model.disabled"
(click)="datepicker.toggle()"> (click)="datepicker.toggle()">

View File

@@ -57,9 +57,9 @@ export class DsDatePickerComponent extends DynamicFormControlComponent implement
ngOnInit() { ngOnInit() {
const now = new Date(); const now = new Date();
this.initialYear = now.getFullYear(); this.initialYear = now.getUTCFullYear();
this.initialMonth = now.getMonth() + 1; this.initialMonth = now.getUTCMonth() + 1;
this.initialDay = now.getDate(); this.initialDay = now.getUTCDate();
if (this.model && this.model.value !== null) { if (this.model && this.model.value !== null) {
const values = this.model.value.toString().split(DS_DATE_PICKER_SEPARATOR); const values = this.model.value.toString().split(DS_DATE_PICKER_SEPARATOR);

View File

@@ -11,6 +11,7 @@
<div *ngFor="let item of columnItems" class="custom-control custom-checkbox"> <div *ngFor="let item of columnItems" class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" <input type="checkbox" class="custom-control-input"
[attr.aria-labelledby]="'label_' + model.id"
[attr.tabindex]="item.index" [attr.tabindex]="item.index"
[checked]="item.value" [checked]="item.value"
[id]="item.id" [id]="item.id"

View File

@@ -11,6 +11,7 @@
[authorityValue]="model.value" [authorityValue]="model.value"
(whenClickOnConfidenceNotAccepted)="whenClickOnConfidenceNotAccepted(sdRef, $event)"></i> (whenClickOnConfidenceNotAccepted)="whenClickOnConfidenceNotAccepted(sdRef, $event)"></i>
<input class="form-control" <input class="form-control"
[attr.aria-labelledby]="'label_' + model.id"
[attr.autoComplete]="model.autoComplete" [attr.autoComplete]="model.autoComplete"
[class.is-invalid]="showErrorMessages" [class.is-invalid]="showErrorMessages"
[id]="model.id" [id]="model.id"
@@ -30,6 +31,7 @@
<div *ngIf="isLookupName()" class="col" > <div *ngIf="isLookupName()" class="col" >
<input class="form-control" <input class="form-control"
[ngClass]="{}" [ngClass]="{}"
[attr.aria-labelledby]="'label_' + model.id"
[attr.autoComplete]="model.autoComplete" [attr.autoComplete]="model.autoComplete"
[class.is-invalid]="showErrorMessages" [class.is-invalid]="showErrorMessages"
[id]="id" [id]="id"
@@ -84,7 +86,7 @@
class="dropdown-menu scrollable-dropdown-menu w-100" class="dropdown-menu scrollable-dropdown-menu w-100"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
aria-labelledby="scrollableDropdownMenuButton"> [attr.aria-labelledby]="'label_' + model.id">
<div class="scrollable-menu" <div class="scrollable-menu"
aria-labelledby="scrollableDropdownMenuButton" aria-labelledby="scrollableDropdownMenuButton"
infiniteScroll infiniteScroll
@@ -112,7 +114,7 @@
</div> </div>
<ng-template #hasInfo let-entry="entry"> <ng-template #hasInfo let-entry="entry">
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0" [attr.aria-labelledby]="'label_' + model.id">
<li class="list-item text-truncate text-primary font-weight-bold">{{entry.value}}</li> <li class="list-item text-truncate text-primary font-weight-bold">{{entry.value}}</li>
<li class="list-item text-truncate text-secondary" *ngFor="let item of entry.otherInformation | dsObjNgFor" > <li class="list-item text-truncate text-secondary" *ngFor="let item of entry.otherInformation | dsObjNgFor" >
{{ 'form.other-information.' + item.key | translate }} : {{item.value}} {{ 'form.other-information.' + item.key | translate }} : {{item.value}}
@@ -121,7 +123,7 @@
</ng-template> </ng-template>
<ng-template #noInfo let-entry="entry"> <ng-template #noInfo let-entry="entry">
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0" [attr.aria-labelledby]="'label_' + model.id">
<li class="list-item text-truncate text-primary font-weight-bold">{{entry.value}}</li> <li class="list-item text-truncate text-primary font-weight-bold">{{entry.value}}</li>
</ul> </ul>
</ng-template> </ng-template>

View File

@@ -30,6 +30,7 @@
(whenClickOnConfidenceNotAccepted)="whenClickOnConfidenceNotAccepted($event)"></i> (whenClickOnConfidenceNotAccepted)="whenClickOnConfidenceNotAccepted($event)"></i>
<input #instance="ngbTypeahead" <input #instance="ngbTypeahead"
class="form-control" class="form-control"
[attr.aria-labelledby]="'label_' + model.id"
[attr.autoComplete]="model.autoComplete" [attr.autoComplete]="model.autoComplete"
[class.is-invalid]="showErrorMessages" [class.is-invalid]="showErrorMessages"
[id]="model.id" [id]="model.id"
@@ -50,19 +51,24 @@
<div class="invalid-feedback" *ngIf="searchFailed">Sorry, suggestions could not be loaded.</div> <div class="invalid-feedback" *ngIf="searchFailed">Sorry, suggestions could not be loaded.</div>
</div> </div>
<input *ngIf="(isHierarchicalVocabulary() | async)" <div *ngIf="(isHierarchicalVocabulary() | async)" class="position-relative right-addon">
class="form-control custom-select" <i class="dropdown-toggle position-absolute tree-toggle" (click)="openTree($event)"
[attr.autoComplete]="model.autoComplete" aria-hidden="true"></i>
[class.is-invalid]="showErrorMessages" <input class="form-control"
[id]="id" [attr.aria-labelledby]="'label_' + model.id"
[name]="model.name" [attr.autoComplete]="model.autoComplete"
[placeholder]="model.placeholder" [class.is-invalid]="showErrorMessages"
[readonly]="model.readOnly" [class.tree-input]="!model.readOnly"
[type]="model.inputType" [id]="id"
[value]="currentValue?.display" [name]="model.name"
(focus)="onFocus($event)" [placeholder]="model.placeholder"
(change)="onChange($event)" [readonly]="true"
(click)="openTree($event)" [type]="model.inputType"
(keydown)="$event.preventDefault()" [value]="currentValue?.display"
(keypress)="$event.preventDefault()" (focus)="onFocus($event)"
(keyup)="$event.preventDefault()"> (change)="onChange($event)"
(click)="openTree($event)"
(keydown)="$event.preventDefault()"
(keypress)="$event.preventDefault()"
(keyup)="$event.preventDefault()">
</div>

View File

@@ -1,3 +1,5 @@
@import '../../../../form.component';
:host ::ng-deep .dropdown-menu { :host ::ng-deep .dropdown-menu {
width: 100% !important; width: 100% !important;
max-height: var(--ds-dropdown-menu-max-height); max-height: var(--ds-dropdown-menu-max-height);
@@ -21,3 +23,11 @@
max-height: 85vh !important; max-height: 85vh !important;
overflow-y: auto; overflow-y: auto;
} }
.tree-toggle {
padding: 0.70rem 0.70rem 0 0.70rem ;
}
.tree-input[readonly]{
background-color: #fff;
}

View File

@@ -1,24 +1,30 @@
<div #sdRef="ngbDropdown" ngbDropdown class="w-100"> <div #sdRef="ngbDropdown" ngbDropdown display="dynamic" placement="bottom-right" class="w-100">
<input ngbDropdownToggle class="form-control custom-select" <div class="position-relative right-addon">
[attr.autoComplete]="model.autoComplete" <i ngbDropdownToggle class="position-absolute scrollable-dropdown-toggle"
[class.is-invalid]="showErrorMessages" aria-hidden="true"></i>
[id]="id" <input class="form-control"
[name]="model.name" [attr.aria-label]="model.placeholder"
[readonly]="model.readOnly" [attr.autoComplete]="model.autoComplete"
[type]="model.inputType" [class.is-invalid]="showErrorMessages"
[value]="(currentValue | async)" [class.scrollable-dropdown-input]="!model.readOnly"
(blur)="onBlur($event)" [id]="id"
(click)="$event.stopPropagation(); openDropdown(sdRef);" [name]="model.name"
(focus)="onFocus($event)" [readonly]="true"
(keypress)="$event.preventDefault()"> [type]="model.inputType"
[value]="(currentValue | async)"
(blur)="onBlur($event)"
(click)="$event.stopPropagation(); openDropdown(sdRef);"
(focus)="onFocus($event)"
(keypress)="$event.preventDefault()">
</div>
<div ngbDropdownMenu <div ngbDropdownMenu
class="dropdown-menu scrollable-dropdown-menu w-100" class="dropdown-menu scrollable-dropdown-menu w-100"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
aria-labelledby="scrollableDropdownMenuButton"> [attr.aria-label]="model.placeholder">
<div class="scrollable-menu" <div class="scrollable-menu"
aria-labelledby="scrollableDropdownMenuButton" [attr.aria-label]="model.placeholder"
infiniteScroll infiniteScroll
[infiniteScrollDistance]="2" [infiniteScrollDistance]="2"
[infiniteScrollThrottle]="50" [infiniteScrollThrottle]="50"

View File

@@ -24,3 +24,11 @@
margin-bottom: var(--bs-spacer); margin-bottom: var(--bs-spacer);
z-index: 1000; z-index: 1000;
} }
.scrollable-dropdown-toggle {
padding: 0.70rem 0.70rem 0 0.70rem ;
}
.scrollable-dropdown-input[readonly]{
background-color: #fff;
}

View File

@@ -129,7 +129,7 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => {
}); });
it('should display dropdown menu entries', () => { it('should display dropdown menu entries', () => {
const de = scrollableDropdownFixture.debugElement.query(By.css('input.custom-select')); const de = scrollableDropdownFixture.debugElement.query(By.css('input.form-control'));
const btnEl = de.nativeElement; const btnEl = de.nativeElement;
const deMenu = scrollableDropdownFixture.debugElement.query(By.css('div.scrollable-dropdown-menu')); const deMenu = scrollableDropdownFixture.debugElement.query(By.css('div.scrollable-dropdown-menu'));
@@ -156,7 +156,7 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => {
it('should select a results entry properly', fakeAsync(() => { it('should select a results entry properly', fakeAsync(() => {
const selectedValue = Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 }); const selectedValue = Object.assign(new VocabularyEntry(), { authority: 1, display: 'one', value: 1 });
let de: any = scrollableDropdownFixture.debugElement.query(By.css('input.custom-select')); let de: any = scrollableDropdownFixture.debugElement.query(By.css('input.form-control'));
let btnEl = de.nativeElement; let btnEl = de.nativeElement;
btnEl.click(); btnEl.click();

View File

@@ -11,6 +11,7 @@
<input *ngIf="!model.hasAuthority" <input *ngIf="!model.hasAuthority"
class="border-0 form-control-plaintext tag-input flex-grow-1 mt-1 mb-1" class="border-0 form-control-plaintext tag-input flex-grow-1 mt-1 mb-1"
type="text" type="text"
role="textbox"
[class.pl-3]="chips.hasItems()" [class.pl-3]="chips.hasItems()"
[placeholder]="model.placeholder" [placeholder]="model.placeholder"
[readonly]="model.readOnly" [readonly]="model.readOnly"
@@ -20,31 +21,32 @@
(keydown)="preventEventsPropagation($event)" (keydown)="preventEventsPropagation($event)"
(keyup)="onKeyUp($event)" /> (keyup)="onKeyUp($event)" />
<div *ngIf="model.hasAuthority" class="position-relative right-addon">
<input *ngIf="model.hasAuthority"
class="border-0 form-control-plaintext tag-input flex-grow-1 mt-1 mb-1"
type="text"
[(ngModel)]="currentValue"
[attr.autoComplete]="model.autoComplete"
[class.is-invalid]="showErrorMessages"
[class.pl-3]="chips.hasItems()"
[id]="id"
[inputFormatter]="formatter"
[name]="model.name"
[ngbTypeahead]="search"
[placeholder]="model.placeholder"
[readonly]="model.readOnly"
[resultTemplate]="rt"
[type]="model.inputType"
(blur)="onBlur($event)"
(focus)="onFocus($event)"
(change)="$event.stopPropagation()"
(input)="onInput($event)"
(selectItem)="onSelectItem($event)"
(keypress)="preventEventsPropagation($event)"
(keydown)="preventEventsPropagation($event)"
(keyup)="onKeyUp($event)"
#instance="ngbTypeahead"/>
<i *ngIf="searching" class="fas fa-circle-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i> <i *ngIf="searching" class="fas fa-circle-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i>
<input class="border-0 form-control-plaintext tag-input flex-grow-1 mt-1 mb-1"
type="text"
[attr.aria-labelledby]="'label_' + model.id"
[(ngModel)]="currentValue"
[attr.autoComplete]="model.autoComplete"
[class.is-invalid]="showErrorMessages"
[class.pl-3]="chips.hasItems()"
[id]="id"
[inputFormatter]="formatter"
[name]="model.name"
[ngbTypeahead]="search"
[placeholder]="model.placeholder"
[readonly]="model.readOnly"
[resultTemplate]="rt"
[type]="model.inputType"
(blur)="onBlur($event)"
(focus)="onFocus($event)"
(change)="$event.stopPropagation()"
(input)="onInput($event)"
(selectItem)="onSelectItem($event)"
(keypress)="preventEventsPropagation($event)"
(keydown)="preventEventsPropagation($event)"
(keyup)="onKeyUp($event)"
#instance="ngbTypeahead"/>
</div>
</ds-chips> </ds-chips>

View File

@@ -1,8 +1,5 @@
/* style fa-spin */ @import '../../../../form.component';
.fa-spin {
pointer-events: none;
right: 0;
}
.chips-left { .chips-left {
left: 0; left: 0;
padding-right: 100%; padding-right: 100%;

View File

@@ -15,20 +15,22 @@
<!--Array with repeatable items--> <!--Array with repeatable items-->
<div *ngIf="(!context.notRepeatable) && !isVirtual(context, index)" <div *ngIf="(!context.notRepeatable) && !isVirtual(context, index)"
class="col-xs-2 d-flex flex-column justify-content-sm-start align-items-end"> class="col-xs-2 d-flex flex-column justify-content-sm-start align-items-end">
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary" role="button"
title="{{'form.remove' | translate}}" title="{{'form.remove' | translate}}"
attr.aria-label="{{'form.remove' | translate}}"
(click)="removeItem($event, context, index)" (click)="removeItem($event, context, index)"
[disabled]="group.context.groups.length === 1 || isItemReadOnly(context, index)"> [disabled]="group.context.groups.length === 1 || isItemReadOnly(context, index)">
<span attr.aria-label="{{'form.remove' | translate}}"><i class="fas fa-trash" aria-hidden="true"></i></span> <span><i class="fas fa-trash" aria-hidden="true"></i></span>
</button> </button>
</div> </div>
<div *ngIf="(!context.notRepeatable) && index === (group.context.groups.length - 1)" class="clearfix pl-4 w-100"> <div *ngIf="(!context.notRepeatable) && index === (group.context.groups.length - 1)" class="clearfix pl-4 w-100">
<div class="btn-group" role="group" aria-label="remove button"> <div class="btn-group" role="group">
<button type="button" class="ds-form-add-more btn btn-link" <button type="button" role="button" class="ds-form-add-more btn btn-link"
title="{{'form.add' | translate}}" title="{{'form.add' | translate}}"
attr.aria-label="{{'form.add' | translate}}"
[disabled]="isItemReadOnly(context, index)" [disabled]="isItemReadOnly(context, index)"
(click)="insertItem($event, group.context, group.context.groups.length)"> (click)="insertItem($event, group.context, group.context.groups.length)">
<span attr.aria-label="{{'form.add' | translate}}"><i class="fas fa-plus"></i> {{'form.add' | translate}}</span> <span><i class="fas fa-plus"></i> {{'form.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -36,12 +38,13 @@
<!--Array with non repeatable items - Only discard button--> <!--Array with non repeatable items - Only discard button-->
<div *ngIf="context.notRepeatable && context.showButtons && group.context.groups.length > 1" <div *ngIf="context.notRepeatable && context.showButtons && group.context.groups.length > 1"
class="col-xs-2 d-flex flex-column justify-content-sm-start align-items-end"> class="col-xs-2 d-flex flex-column justify-content-sm-start align-items-end">
<div class="btn-group" role="group" aria-label="Remove button"> <div class="btn-group" role="button">
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
title="{{'form.discard' | translate}}" title="{{'form.discard' | translate}}"
attr.aria-label="{{'form.discard' | translate}}"
(click)="removeItem($event, context, index)" (click)="removeItem($event, context, index)"
[disabled]="group.context.groups.length === 1 || isItemReadOnly(context, index)"> [disabled]="group.context.groups.length === 1 || isItemReadOnly(context, index)">
<span attr.aria-label="{{'form.discard' | translate}}">{{'form.discard' | translate}}</span> <span>{{'form.discard' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -55,15 +58,15 @@
<hr> <hr>
<div class="form-group row"> <div class="form-group row">
<div class="col text-right"> <div class="col text-right">
<ng-content select="[before]"></ng-content> <ng-content select="[before]"></ng-content>
<button *ngIf="displayCancel" type="reset" class="btn btn-outline-secondary" (click)="reset()"> <button *ngIf="displayCancel" type="reset" class="btn btn-outline-secondary" (click)="reset()">
<i class="fa fa-times"></i> {{cancelLabel | translate}} <i class="fa fa-times"></i> {{cancelLabel | translate}}
</button> </button>
<ng-content select="[between]"></ng-content> <ng-content select="[between]"></ng-content>
<button *ngIf="displaySubmit" type="submit" class="btn btn-primary" (click)="onSubmit()" <button *ngIf="displaySubmit" type="submit" class="btn btn-primary" (click)="onSubmit()"
[disabled]="!(isValid() | async)"><i class="fas fa-save"></i> {{submitLabel | translate}} [disabled]="!(isValid() | async)"><i class="fas fa-save"></i> {{submitLabel | translate}}
</button> </button>
<ng-content select="[after]"></ng-content> <ng-content select="[after]"></ng-content>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -31,11 +31,11 @@ button.ds-form-add-more:focus {
} }
/* align fa-spin */ /* align fa-spin */
.left-addon .far, .left-addon .fas { .left-addon .far, .left-addon .fas, .left-addon i {
left: 0; left: 0;
} }
.right-addon .far, .right-addon .fas { .right-addon .far, .right-addon .fas, .right-addon i {
right: 0; right: 0;
} }

View File

@@ -0,0 +1,18 @@
<div>
<div class="modal-header" id="idle-modal.header">{{ "idle-modal.header" | translate }}
<button type="button" class="close" (click)="closePressed()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<p>{{ "idle-modal.info" | translate:{timeToExpire: timeToExpire} }}</p>
</div>
<div class="modal-footer">
<button type="button" class="cancel btn btn-danger" (click)="logOutPressed()" aria-label="Log out">
<i class="fas fa-sign-out-alt"></i> {{ "idle-modal.log-out" | translate }}
</button>
<button type="button" class="confirm btn btn-primary" (click)="extendSessionPressed()" aria-label="Extend session" ngbAutofocus>
<i class="fas fa-redo-alt"></i> {{ "idle-modal.extend-session" | translate }}
</button>
</div>
</div>

View File

@@ -0,0 +1,135 @@
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { IdleModalComponent } from './idle-modal.component';
import { AuthService } from '../../core/auth/auth.service';
import { By } from '@angular/platform-browser';
import { Store } from '@ngrx/store';
import { LogOutAction } from '../../core/auth/auth.actions';
describe('IdleModalComponent', () => {
let component: IdleModalComponent;
let fixture: ComponentFixture<IdleModalComponent>;
let debugElement: DebugElement;
let modalStub;
let authServiceStub;
let storeStub;
beforeEach(waitForAsync(() => {
modalStub = jasmine.createSpyObj('modalStub', ['close']);
authServiceStub = jasmine.createSpyObj('authService', ['setIdle']);
storeStub = jasmine.createSpyObj('store', ['dispatch']);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [IdleModalComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{ provide: AuthService, useValue: authServiceStub },
{ provide: Store, useValue: storeStub }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(IdleModalComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('extendSessionPressed', () => {
beforeEach(fakeAsync(() => {
spyOn(component.response, 'next');
component.extendSessionPressed();
}));
it('should set idle to false', () => {
expect(authServiceStub.setIdle).toHaveBeenCalledWith(false);
});
it('should close the modal', () => {
expect(modalStub.close).toHaveBeenCalled();
});
it('response \'closed\' should have true as next', () => {
expect(component.response.next).toHaveBeenCalledWith(true);
});
});
describe('logOutPressed', () => {
beforeEach(() => {
component.logOutPressed();
});
it('should close the modal', () => {
expect(modalStub.close).toHaveBeenCalled();
});
it('should send logout action', () => {
expect(storeStub.dispatch).toHaveBeenCalledWith(new LogOutAction());
});
});
describe('closePressed', () => {
beforeEach(fakeAsync(() => {
spyOn(component.response, 'next');
component.closePressed();
}));
it('should set idle to false', () => {
expect(authServiceStub.setIdle).toHaveBeenCalledWith(false);
});
it('should close the modal', () => {
expect(modalStub.close).toHaveBeenCalled();
});
it('response \'closed\' should have true as next', () => {
expect(component.response.next).toHaveBeenCalledWith(true);
});
});
describe('when the click method emits on extend session button', () => {
beforeEach(fakeAsync(() => {
spyOn(component, 'extendSessionPressed');
debugElement.query(By.css('button.confirm')).triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('should call the extendSessionPressed method on the component', () => {
expect(component.extendSessionPressed).toHaveBeenCalled();
});
});
describe('when the click method emits on log out button', () => {
beforeEach(fakeAsync(() => {
spyOn(component, 'logOutPressed');
debugElement.query(By.css('button.cancel')).triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('should call the logOutPressed method on the component', () => {
expect(component.logOutPressed).toHaveBeenCalled();
});
});
describe('when the click method emits on close button', () => {
beforeEach(fakeAsync(() => {
spyOn(component, 'closePressed');
debugElement.query(By.css('.close')).triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('should call the closePressed method on the component', () => {
expect(component.closePressed).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,89 @@
import { Component, OnInit, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { environment } from '../../../environments/environment';
import { AuthService } from '../../core/auth/auth.service';
import { Subject } from 'rxjs';
import { hasValue } from '../empty.util';
import { Store } from '@ngrx/store';
import { AppState } from '../../app.reducer';
import { LogOutAction } from '../../core/auth/auth.actions';
@Component({
selector: 'ds-idle-modal',
templateUrl: 'idle-modal.component.html',
})
export class IdleModalComponent implements OnInit {
/**
* Total time of idleness before session expires (in minutes)
* (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod / 1000 / 60)
*/
timeToExpire: number;
/**
* Timer to track time grace period
*/
private graceTimer;
/**
* An event fired when the modal is closed
*/
@Output()
response: Subject<boolean> = new Subject();
constructor(private activeModal: NgbActiveModal,
private authService: AuthService,
private store: Store<AppState>) {
this.timeToExpire = (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod) / 1000 / 60; // ms => min
}
ngOnInit() {
if (hasValue(this.graceTimer)) {
clearTimeout(this.graceTimer);
}
this.graceTimer = setTimeout(() => {
this.logOutPressed();
}, environment.auth.ui.idleGracePeriod);
}
/**
* When extend session is pressed
*/
extendSessionPressed() {
this.extendSessionAndCloseModal();
}
/**
* Close modal and logout
*/
logOutPressed() {
this.closeModal();
this.store.dispatch(new LogOutAction());
}
/**
* When close is pressed
*/
closePressed() {
this.extendSessionAndCloseModal();
}
/**
* Close the modal and extend session
*/
extendSessionAndCloseModal() {
if (hasValue(this.graceTimer)) {
clearTimeout(this.graceTimer);
}
this.authService.setIdle(false);
this.closeModal();
}
/**
* Close the modal and set the response to true so RootComponent knows the modal was closed
*/
closeModal() {
this.activeModal.close();
this.response.next(true);
}
}

View File

@@ -3,7 +3,7 @@
(keydown.arrowdown)="shiftFocusDown($event)" (keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()" (keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="checkIfValidInput(form);close();"> (dsClickOutside)="checkIfValidInput(form);close();">
<input #inputField type="text" formControlName="metadataNameField" [(ngModel)]="value" id="name" [name]="name" <input #inputField type="text" formControlName="metadataNameField" attr.aria-labelledby="fieldName" [(ngModel)]="value" id="name" [name]="name"
class="form-control suggestion_input" class="form-control suggestion_input"
[ngClass]="{'is-invalid': !valid}" [ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)" [dsDebounce]="debounceTime" (onDebounce)="find($event)"

View File

@@ -16,7 +16,7 @@ import { VersionHistoryDataService } from '../../../core/data/version-history-da
import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../search/paginated-search-options.model';
import { AlertType } from '../../alert/aletr-type'; import { AlertType } from '../../alert/aletr-type';
import { followLink } from '../../utils/follow-link-config.model'; import { followLink } from '../../utils/follow-link-config.model';
import { hasValueOperator } from '../../empty.util'; import { hasValue, hasValueOperator } from '../../empty.util';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { getItemPageRoute } from '../../../+item-page/item-page-routing-paths'; import { getItemPageRoute } from '../../../+item-page/item-page-routing-paths';
@@ -110,41 +110,43 @@ export class ItemVersionsComponent implements OnInit {
* Initialize all observables * Initialize all observables
*/ */
ngOnInit(): void { ngOnInit(): void {
this.versionRD$ = this.item.version; if (hasValue(this.item.version)) {
this.versionHistoryRD$ = this.versionRD$.pipe( this.versionRD$ = this.item.version;
getAllSucceededRemoteData(), this.versionHistoryRD$ = this.versionRD$.pipe(
getRemoteDataPayload(), getAllSucceededRemoteData(),
hasValueOperator(), getRemoteDataPayload(),
switchMap((version: Version) => version.versionhistory) hasValueOperator(),
); switchMap((version: Version) => version.versionhistory)
const versionHistory$ = this.versionHistoryRD$.pipe( );
getAllSucceededRemoteData(), const versionHistory$ = this.versionHistoryRD$.pipe(
getRemoteDataPayload(), getAllSucceededRemoteData(),
hasValueOperator(), getRemoteDataPayload(),
); hasValueOperator(),
const currentPagination = this.paginationService.getCurrentPagination(this.options.id, this.options); );
this.versionsRD$ = observableCombineLatest(versionHistory$, currentPagination).pipe( const currentPagination = this.paginationService.getCurrentPagination(this.options.id, this.options);
switchMap(([versionHistory, options]: [VersionHistory, PaginationComponentOptions]) => this.versionsRD$ = observableCombineLatest(versionHistory$, currentPagination).pipe(
this.versionHistoryService.getVersions(versionHistory.id, switchMap(([versionHistory, options]: [VersionHistory, PaginationComponentOptions]) =>
new PaginatedSearchOptions({pagination: Object.assign({}, options, { currentPage: options.currentPage })}), this.versionHistoryService.getVersions(versionHistory.id,
true, true, followLink('item'), followLink('eperson'))) new PaginatedSearchOptions({pagination: Object.assign({}, options, { currentPage: options.currentPage })}),
); true, true, followLink('item'), followLink('eperson')))
this.hasEpersons$ = this.versionsRD$.pipe( );
getAllSucceededRemoteData(), this.hasEpersons$ = this.versionsRD$.pipe(
getRemoteDataPayload(), getAllSucceededRemoteData(),
hasValueOperator(), getRemoteDataPayload(),
map((versions: PaginatedList<Version>) => versions.page.filter((version: Version) => version.eperson !== undefined).length > 0), hasValueOperator(),
startWith(false) map((versions: PaginatedList<Version>) => versions.page.filter((version: Version) => version.eperson !== undefined).length > 0),
); startWith(false)
this.itemPageRoutes$ = this.versionsRD$.pipe( );
getAllSucceededRemoteDataPayload(), this.itemPageRoutes$ = this.versionsRD$.pipe(
switchMap((versions) => observableCombineLatest(...versions.page.map((version) => version.item.pipe(getAllSucceededRemoteDataPayload())))), getAllSucceededRemoteDataPayload(),
map((versions) => { switchMap((versions) => observableCombineLatest(...versions.page.map((version) => version.item.pipe(getAllSucceededRemoteDataPayload())))),
const itemPageRoutes = {}; map((versions) => {
versions.forEach((item) => itemPageRoutes[item.uuid] = getItemPageRoute(item)); const itemPageRoutes = {};
return itemPageRoutes; versions.forEach((item) => itemPageRoutes[item.uuid] = getItemPageRoute(item));
}) return itemPageRoutes;
); })
);
}
} }
ngOnDestroy(): void { ngOnDestroy(): void {

View File

@@ -1,9 +1,14 @@
<div ngbDropdown class="navbar-nav" *ngIf="moreThanOneLanguage" display="dynamic" placement="bottom-right"> <div ngbDropdown class="navbar-nav" *ngIf="moreThanOneLanguage" display="dynamic" placement="bottom-right">
<a href="#" id="dropdownLang" role="button" [attr.aria-label]="'nav.language' |translate" [title]="'nav.language' | translate" class="px-1" (click)="$event.preventDefault()" data-toggle="dropdown" ngbDropdownToggle> <a href="#" role="button"
[attr.aria-label]="'nav.language' |translate"
[title]="'nav.language' | translate" class="px-1"
(click)="$event.preventDefault()" data-toggle="dropdown" ngbDropdownToggle
tabindex="0">
<i class="fas fa-globe-asia fa-lg fa-fw"></i> <i class="fas fa-globe-asia fa-lg fa-fw"></i>
</a> </a>
<ul ngbDropdownMenu class="dropdown-menu" aria-labelledby="dropdownLang"> <ul ngbDropdownMenu class="dropdown-menu" [attr.aria-label]="'nav.language' |translate">
<li class="dropdown-item" #langSelect *ngFor="let lang of translate.getLangs()" <li class="dropdown-item" tabindex="0" #langSelect *ngFor="let lang of translate.getLangs()"
(keyup.enter)="useLang(lang)"
(click)="useLang(lang)" (click)="useLang(lang)"
[class.active]="lang === translate.currentLang"> [class.active]="lang === translate.currentLang">
{{ langLabel(lang) }} {{ langLabel(lang) }}

View File

@@ -1,8 +1,8 @@
<form class="form-login" <form class="form-login"
(ngSubmit)="submit()" (ngSubmit)="submit()"
[formGroup]="form" novalidate> [formGroup]="form" novalidate>
<label for="inputEmail" class="sr-only">{{"login.form.email" | translate}}</label> <label class="sr-only">{{"login.form.email" | translate}}</label>
<input id="inputEmail" <input [attr.aria-label]="'login.form.email' |translate"
autocomplete="off" autocomplete="off"
autofocus autofocus
class="form-control form-control-lg position-relative" class="form-control form-control-lg position-relative"
@@ -10,8 +10,8 @@
placeholder="{{'login.form.email' | translate}}" placeholder="{{'login.form.email' | translate}}"
required required
type="email"> type="email">
<label for="inputPassword" class="sr-only">{{"login.form.password" | translate}}</label> <label class="sr-only">{{"login.form.password" | translate}}</label>
<input id="inputPassword" <input [attr.aria-label]="'login.form.password' |translate"
autocomplete="off" autocomplete="off"
class="form-control form-control-lg position-relative mb-3" class="form-control form-control-lg position-relative mb-3"
placeholder="{{'login.form.password' | translate}}" placeholder="{{'login.form.password' | translate}}"

View File

@@ -19,4 +19,11 @@ export class AuthServiceMock {
public setRedirectUrl(url: string) { public setRedirectUrl(url: string) {
} }
public trackTokenExpiration(): void {
}
public isUserIdle(): Observable<boolean> {
return observableOf(false);
}
} }

View File

@@ -5,6 +5,7 @@ import { Bitstream } from '../../core/shared/bitstream.model';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { createPaginatedList } from '../testing/utils.test'; import { createPaginatedList } from '../testing/utils.test';
import { Bundle } from '../../core/shared/bundle.model';
export const MockBitstreamFormat1: BitstreamFormat = Object.assign(new BitstreamFormat(), { export const MockBitstreamFormat1: BitstreamFormat = Object.assign(new BitstreamFormat(), {
shortDescription: 'Microsoft Word XML', shortDescription: 'Microsoft Word XML',
@@ -34,11 +35,25 @@ export const MockBitstreamFormat2: BitstreamFormat = Object.assign(new Bitstream
} }
}); });
export const MockBitstreamFormat3: BitstreamFormat = Object.assign(new BitstreamFormat(), {
shortDescription: 'Binary',
description: 'Some scary unknown binary file',
mimetype: 'application/octet-stream',
supportLevel: 0,
internal: false,
extensions: null,
_links:{
self: {
href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/17'
}
}
});
export const MockBitstream1: Bitstream = Object.assign(new Bitstream(), export const MockBitstream1: Bitstream = Object.assign(new Bitstream(),
{ {
sizeBytes: 10201, sizeBytes: 10201,
content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
format: observableOf(MockBitstreamFormat1), format: createSuccessfulRemoteDataObject$(MockBitstreamFormat1),
bundleName: 'ORIGINAL', bundleName: 'ORIGINAL',
_links:{ _links:{
self: { self: {
@@ -61,7 +76,7 @@ export const MockBitstream1: Bitstream = Object.assign(new Bitstream(),
export const MockBitstream2: Bitstream = Object.assign(new Bitstream(), { export const MockBitstream2: Bitstream = Object.assign(new Bitstream(), {
sizeBytes: 31302, sizeBytes: 31302,
content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content', content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content',
format: observableOf(MockBitstreamFormat2), format: createSuccessfulRemoteDataObject$(MockBitstreamFormat2),
bundleName: 'ORIGINAL', bundleName: 'ORIGINAL',
id: '99b00f3c-1cc6-4689-8158-91965bee6b28', id: '99b00f3c-1cc6-4689-8158-91965bee6b28',
uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28', uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28',
@@ -82,6 +97,68 @@ export const MockBitstream2: Bitstream = Object.assign(new Bitstream(), {
} }
}); });
export const MockBitstream3: Bitstream = Object.assign(new Bitstream(), {
sizeBytes: 4975123,
content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/content',
format: createSuccessfulRemoteDataObject$(MockBitstreamFormat3),
bundleName: 'ORIGINAL',
id: '4db100c1-e1f5-4055-9404-9bc3e2d15f29',
uuid: '4db100c1-e1f5-4055-9404-9bc3e2d15f29',
type: 'bitstream',
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29' },
content: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/content' },
format: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/17' },
bundle: { href: '' }
},
metadata: {
'dc.title': [
{
language: null,
value: 'scary'
}
]
}
});
export const MockOriginalBundle: Bundle = Object.assign(new Bundle(), {
name: 'ORIGINAL',
primaryBitstream: createSuccessfulRemoteDataObject$(MockBitstream2),
bitstreams: observableOf(Object.assign({
_links: {
self: {
href: 'dspace-angular://aggregated/object/1507836003548',
}
},
requestPending: false,
responsePending: false,
isSuccessful: true,
errorMessage: '',
state: '',
error: undefined,
isRequestPending: false,
isResponsePending: false,
isLoading: false,
hasFailed: false,
hasSucceeded: true,
statusCode: '202',
pageInfo: {},
payload: {
pageInfo: {
elementsPerPage: 20,
totalElements: 3,
totalPages: 1,
currentPage: 2
},
page: [
MockBitstream1,
MockBitstream2
]
}
}))
});
/* tslint:disable:no-shadowed-variable */ /* tslint:disable:no-shadowed-variable */
export const ItemMock: Item = Object.assign(new Item(), { export const ItemMock: Item = Object.assign(new Item(), {
handle: '10673/6', handle: '10673/6',
@@ -90,41 +167,7 @@ export const ItemMock: Item = Object.assign(new Item(), {
isDiscoverable: true, isDiscoverable: true,
isWithdrawn: false, isWithdrawn: false,
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([ bundles: createSuccessfulRemoteDataObject$(createPaginatedList([
{ MockOriginalBundle,
name: 'ORIGINAL',
bitstreams: observableOf(Object.assign({
_links: {
self: {
href: 'dspace-angular://aggregated/object/1507836003548',
}
},
requestPending: false,
responsePending: false,
isSuccessful: true,
errorMessage: '',
state: '',
error: undefined,
isRequestPending: false,
isResponsePending: false,
isLoading: false,
hasFailed: false,
hasSucceeded: true,
statusCode: '202',
pageInfo: {},
payload: {
pageInfo: {
elementsPerPage: 20,
totalElements: 3,
totalPages: 1,
currentPage: 2
},
page: [
MockBitstream1,
MockBitstream2
]
}
}))
}
])), ])),
_links:{ _links:{
self: { self: {

View File

@@ -5,6 +5,15 @@
[object]="object" [object]="object"
(processCompleted)="this.processCompleted.emit($event)"> (processCompleted)="this.processCompleted.emit($event)">
</ds-claimed-task-actions-loader> </ds-claimed-task-actions-loader>
<ng-container *ngIf="hasViewAction(workflowAction)">
<button class="btn btn-primary workflow-view"
ngbTooltip="{{'submission.workflow.generic.view-help' | translate}}"
[routerLink]="[getWorkflowItemViewRoute((workflowitem$ | async))]">
<i class="fa fa-info-circle"></i> {{"submission.workflow.generic.view" | translate}}
</button>
</ng-container>
<ds-claimed-task-actions-loader [option]="returnToPoolOption" <ds-claimed-task-actions-loader [option]="returnToPoolOption"
[object]="object" [object]="object"
(processCompleted)="this.processCompleted.emit($event)"> (processCompleted)="this.processCompleted.emit($event)">

View File

@@ -23,6 +23,7 @@ import { WorkflowActionDataService } from '../../../core/data/workflow-action-da
import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model';
import { VarDirective } from '../../utils/var.directive'; import { VarDirective } from '../../utils/var.directive';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
import { By } from '@angular/platform-browser';
let component: ClaimedTaskActionsComponent; let component: ClaimedTaskActionsComponent;
let fixture: ComponentFixture<ClaimedTaskActionsComponent>; let fixture: ComponentFixture<ClaimedTaskActionsComponent>;
@@ -81,7 +82,7 @@ function init() {
} }
}); });
rdItem = createSuccessfulRemoteDataObject(item); rdItem = createSuccessfulRemoteDataObject(item);
workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem), id: '333' });
rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem);
mockObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem), id: '1234' }); mockObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem), id: '1234' });
workflowAction = Object.assign(new WorkflowAction(), { id: 'action-1', options: ['option-1', 'option-2'] }); workflowAction = Object.assign(new WorkflowAction(), { id: 'action-1', options: ['option-1', 'option-2'] });
@@ -159,4 +160,26 @@ describe('ClaimedTaskActionsComponent', () => {
expect(notificationsServiceStub.error).toHaveBeenCalled(); expect(notificationsServiceStub.error).toHaveBeenCalled();
}); });
})); }));
describe('when edit options is not available', () => {
it('should display a view button', waitForAsync(() => {
component.object = null;
component.initObjects(mockObject);
fixture.detectChanges();
fixture.whenStable().then(() => {
const debugElement = fixture.debugElement.query(By.css('.workflow-view'));
expect(debugElement).toBeTruthy();
expect(debugElement.nativeElement.innerText).toBe('submission.workflow.generic.view');
});
}));
it('getWorkflowItemViewRoute should return the combined uri to show a workspaceitem', waitForAsync(() => {
const href = component.getWorkflowItemViewRoute(workflowitem);
expect(href).toEqual('/workflowitems/333/view');
}));
});
}); });

View File

@@ -17,6 +17,7 @@ import { SearchService } from '../../../core/shared/search/search.service';
import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model';
import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service';
import { WORKFLOW_TASK_OPTION_RETURN_TO_POOL } from './return-to-pool/claimed-task-actions-return-to-pool.component'; import { WORKFLOW_TASK_OPTION_RETURN_TO_POOL } from './return-to-pool/claimed-task-actions-return-to-pool.component';
import { getWorkflowItemViewRoute } from '../../../+workflowitems-edit-page/workflowitems-edit-page-routing-paths';
/** /**
* This component represents actions related to ClaimedTask object. * This component represents actions related to ClaimedTask object.
@@ -85,6 +86,7 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent<Claime
*/ */
initObjects(object: ClaimedTask) { initObjects(object: ClaimedTask) {
this.object = object; this.object = object;
this.workflowitem$ = (this.object.workflowitem as Observable<RemoteData<WorkflowItem>>).pipe( this.workflowitem$ = (this.object.workflowitem as Observable<RemoteData<WorkflowItem>>).pipe(
filter((rd: RemoteData<WorkflowItem>) => ((!rd.isRequestPending) && isNotUndefined(rd.payload))), filter((rd: RemoteData<WorkflowItem>) => ((!rd.isRequestPending) && isNotUndefined(rd.payload))),
map((rd: RemoteData<WorkflowItem>) => rd.payload), map((rd: RemoteData<WorkflowItem>) => rd.payload),
@@ -100,4 +102,19 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent<Claime
this.actionRD$ = object.action; this.actionRD$ = object.action;
} }
/**
* Check if claimed task actions should display a view item button.
* @param workflowAction
*/
hasViewAction(workflowAction: WorkflowAction) {
return !workflowAction?.options.includes('submit_edit_metadata');
}
/**
* Get the workflowitem view route.
*/
getWorkflowItemViewRoute(workflowitem: WorkflowItem): string {
return getWorkflowItemViewRoute(workflowitem?.id);
}
} }

View File

@@ -2,7 +2,7 @@
<button <button
class="btn btn-link" class="btn btn-link"
type="button" type="button"
tabindex="-1" tabindex="0"
[disabled]="disabled" [disabled]="disabled"
(click)="toggleUp()"> (click)="toggleUp()">
<span class="chevron"></span> <span class="chevron"></span>
@@ -27,7 +27,7 @@
<button <button
class="btn btn-link" class="btn btn-link"
type="button" type="button"
tabindex="-1" tabindex="0"
[disabled]="disabled" [disabled]="disabled"
(click)="toggleDown()"> (click)="toggleDown()">
<span class="chevron bottom"></span> <span class="chevron bottom"></span>

View File

@@ -13,11 +13,10 @@ import { CollectionSelectComponent } from './collection-select.component';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
import { createPaginatedList } from '../../testing/utils.test'; import { createPaginatedList } from '../../testing/utils.test';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { FindListOptions } from '../../../core/data/request.models';
import { of as observableOf } from 'rxjs';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../testing/pagination-service.stub'; import { PaginationServiceStub } from '../../testing/pagination-service.stub';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
describe('CollectionSelectComponent', () => { describe('CollectionSelectComponent', () => {
let comp: CollectionSelectComponent; let comp: CollectionSelectComponent;
@@ -41,6 +40,10 @@ describe('CollectionSelectComponent', () => {
currentPage: 1 currentPage: 1
}); });
const authorizationDataService = jasmine.createSpyObj('authorizationDataService', {
isAuthorized: observableOf(true)
});
const paginationService = new PaginationServiceStub(); const paginationService = new PaginationServiceStub();
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -49,7 +52,8 @@ describe('CollectionSelectComponent', () => {
providers: [ providers: [
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockCollectionList[1].id]) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockCollectionList[1].id]) },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: PaginationService, useValue: paginationService } { provide: PaginationService, useValue: paginationService },
{ provide: AuthorizationDataService, useValue: authorizationDataService }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();

View File

@@ -3,6 +3,7 @@ import { Collection } from '../../../core/shared/collection.model';
import { ObjectSelectComponent } from '../object-select/object-select.component'; import { ObjectSelectComponent } from '../object-select/object-select.component';
import { isNotEmpty } from '../../empty.util'; import { isNotEmpty } from '../../empty.util';
import { ObjectSelectService } from '../object-select.service'; import { ObjectSelectService } from '../object-select.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
@Component({ @Component({
selector: 'ds-collection-select', selector: 'ds-collection-select',
@@ -14,8 +15,9 @@ import { ObjectSelectService } from '../object-select.service';
*/ */
export class CollectionSelectComponent extends ObjectSelectComponent<Collection> { export class CollectionSelectComponent extends ObjectSelectComponent<Collection> {
constructor(protected objectSelectService: ObjectSelectService) { constructor(protected objectSelectService: ObjectSelectService,
super(objectSelectService); protected authorizationService: AuthorizationDataService) {
super(objectSelectService, authorizationService);
} }
ngOnInit(): void { ngOnInit(): void {

View File

@@ -19,7 +19,7 @@
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let item of itemsRD?.payload?.page"> <tr *ngFor="let item of itemsRD?.payload?.page">
<td><input class="item-checkbox" [ngModel]="getSelected(item.id) | async" (change)="switch(item.id)" type="checkbox" name="{{item.id}}"></td> <td><input [disabled]="!(canSelect(item) | async)" class="item-checkbox" [ngModel]="getSelected(item.id) | async" (change)="switch(item.id)" type="checkbox" name="{{item.id}}"></td>
<td *ngIf="!hideCollection"> <td *ngIf="!hideCollection">
<span *ngVar="(item.owningCollection | async)?.payload as collection"> <span *ngVar="(item.owningCollection | async)?.payload as collection">
<a *ngIf="collection" [routerLink]="['/collections', collection?.id]">{{collection?.name}}</a> <a *ngIf="collection" [routerLink]="['/collections', collection?.id]">{{collection?.name}}</a>

View File

@@ -11,13 +11,13 @@ import { HostWindowService } from '../../host-window.service';
import { HostWindowServiceStub } from '../../testing/host-window-service.stub'; import { HostWindowServiceStub } from '../../testing/host-window-service.stub';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { of as observableOf, of } from 'rxjs'; import { of } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
import { createPaginatedList } from '../../testing/utils.test'; import { createPaginatedList } from '../../testing/utils.test';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { FindListOptions } from '../../../core/data/request.models';
import { PaginationServiceStub } from '../../testing/pagination-service.stub'; import { PaginationServiceStub } from '../../testing/pagination-service.stub';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
describe('ItemSelectComponent', () => { describe('ItemSelectComponent', () => {
let comp: ItemSelectComponent; let comp: ItemSelectComponent;
@@ -39,7 +39,8 @@ describe('ItemSelectComponent', () => {
key: 'dc.type', key: 'dc.type',
language: null, language: null,
value: 'Article' value: 'Article'
}] }],
_links: { self: { href: 'selfId1' } }
}), }),
Object.assign(new Item(), { Object.assign(new Item(), {
id: 'id2', id: 'id2',
@@ -54,7 +55,8 @@ describe('ItemSelectComponent', () => {
key: 'dc.type', key: 'dc.type',
language: null, language: null,
value: 'Article' value: 'Article'
}] }],
_links: { self: { href: 'selfId2' } }
}) })
]; ];
const mockItems = createSuccessfulRemoteDataObject$(createPaginatedList(mockItemList)); const mockItems = createSuccessfulRemoteDataObject$(createPaginatedList(mockItemList));
@@ -66,6 +68,7 @@ describe('ItemSelectComponent', () => {
paginationService = new PaginationServiceStub(mockPaginationOptions); paginationService = new PaginationServiceStub(mockPaginationOptions);
const authorizationDataService = new AuthorizationDataService(null, null, null, null, null, null, null, null, null, null);
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
@@ -75,7 +78,8 @@ describe('ItemSelectComponent', () => {
providers: [ providers: [
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockItemList[1].id]) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockItemList[1].id]) },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: PaginationService, useValue: paginationService } { provide: PaginationService, useValue: paginationService },
{ provide: AuthorizationDataService, useValue: authorizationDataService }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();
@@ -146,4 +150,21 @@ describe('ItemSelectComponent', () => {
expect(comp.cancel.emit).toHaveBeenCalled(); expect(comp.cancel.emit).toHaveBeenCalled();
}); });
}); });
describe('when the authorize feature is not authorized', () => {
beforeEach(() => {
comp.featureId = FeatureID.CanManageMappings;
spyOn(authorizationDataService, 'isAuthorized').and.returnValue(of(false));
});
it('should disable the checkbox', waitForAsync(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
const checkbox = fixture.debugElement.query(By.css('input.item-checkbox')).nativeElement;
expect(authorizationDataService.isAuthorized).toHaveBeenCalled();
expect(checkbox.disabled).toBeTrue();
});
}));
});
}); });

View File

@@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
import { getAllSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { getAllSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { getItemPageRoute } from '../../../+item-page/item-page-routing-paths'; import { getItemPageRoute } from '../../../+item-page/item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
@Component({ @Component({
selector: 'ds-item-select', selector: 'ds-item-select',
@@ -33,8 +34,9 @@ export class ItemSelectComponent extends ObjectSelectComponent<Item> {
[itemId: string]: string [itemId: string]: string
}>; }>;
constructor(protected objectSelectService: ObjectSelectService) { constructor(protected objectSelectService: ObjectSelectService,
super(objectSelectService); protected authorizationService: AuthorizationDataService ) {
super(objectSelectService, authorizationService);
} }
ngOnInit(): void { ngOnInit(): void {

View File

@@ -1,11 +1,15 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { take } from 'rxjs/operators'; import { startWith, take } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
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 '../../pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model';
import { ObjectSelectService } from '../object-select.service'; import { ObjectSelectService } from '../object-select.service';
import { SortOptions } from '../../../core/cache/models/sort-options.model'; import { SortOptions } from '../../../core/cache/models/sort-options.model';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { of } from 'rxjs/internal/observable/of';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
/** /**
* An abstract component used to select DSpaceObjects from a specific list and returning the UUIDs of the selected DSpaceObjects * An abstract component used to select DSpaceObjects from a specific list and returning the UUIDs of the selected DSpaceObjects
@@ -47,6 +51,12 @@ export abstract class ObjectSelectComponent<TDomain> implements OnInit, OnDestro
@Input() @Input()
confirmButton: string; confirmButton: string;
/**
* Authorize check to enable the selection when present.
*/
@Input()
featureId: FeatureID;
/** /**
* The message key used for the cancel button * The message key used for the cancel button
* @type {string} * @type {string}
@@ -79,7 +89,8 @@ export abstract class ObjectSelectComponent<TDomain> implements OnInit, OnDestro
*/ */
selectedIds$: Observable<string[]>; selectedIds$: Observable<string[]>;
constructor(protected objectSelectService: ObjectSelectService) { constructor(protected objectSelectService: ObjectSelectService,
protected authorizationService: AuthorizationDataService) {
} }
ngOnInit(): void { ngOnInit(): void {
@@ -107,6 +118,16 @@ export abstract class ObjectSelectComponent<TDomain> implements OnInit, OnDestro
return this.objectSelectService.getSelected(this.key, id); return this.objectSelectService.getSelected(this.key, id);
} }
/**
* Return if the item can be selected or not due to authorization check.
*/
canSelect(item: DSpaceObject): Observable<boolean> {
if (!this.featureId) {
return of(true);
}
return this.authorizationService.isAuthorized(this.featureId, item.self).pipe(startWith(false));
}
/** /**
* Called when the confirm button is pressed * Called when the confirm button is pressed
* Sends the selected UUIDs to the parent component * Sends the selected UUIDs to the parent component

View File

@@ -1,6 +1,6 @@
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="row" action="/search"> <form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="row" action="/search">
<div *ngIf="isNotEmpty(scopes)" class="col-12 col-sm-3"> <div *ngIf="isNotEmpty(scopes)" class="col-12 col-sm-3">
<select [(ngModel)]="scope" name="scope" class="form-control" aria-label="Search scope" (change)="onScopeChange($event.target.value)"> <select [(ngModel)]="scope" name="scope" class="form-control" aria-label="Search scope" (change)="onScopeChange($event.target.value)" tabindex="0">
<option value>{{'search.form.search_dspace' | translate}}</option> <option value>{{'search.form.search_dspace' | translate}}</option>
<option *ngFor="let scopeOption of scopes" [value]="scopeOption.id">{{scopeOption?.name ? scopeOption.name : 'search.form.search_dspace' | translate}}</option> <option *ngFor="let scopeOption of scopes" [value]="scopeOption.id">{{scopeOption?.name ? scopeOption.name : 'search.form.search_dspace' | translate}}</option>
</select> </select>

View File

@@ -56,7 +56,7 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
/** /**
* Fallback maximum for the range * Fallback maximum for the range
*/ */
max = new Date().getFullYear(); max = new Date().getUTCFullYear();
/** /**
* The current range of the filter * The current range of the filter

View File

@@ -1,7 +1,8 @@
<div *ngIf="configurationList?.length > 1" class="search-switch-configuration"> <div *ngIf="configurationList?.length > 1" class="search-switch-configuration">
<h5>{{ 'search.switch-configuration.title' | translate}}</h5> <h5 id="configuration-switch">{{ 'search.switch-configuration.title' | translate}}</h5>
<select class="form-control" <select class="form-control"
aria-labelledby="configuration-switch"
[compareWith]="compare" [compareWith]="compare"
[(ngModel)]="selectedOption" [(ngModel)]="selectedOption"
(change)="onSelect()"> (change)="onSelect()">

View File

@@ -10,6 +10,8 @@ export class SectionsServiceStub {
isSectionEnabled = jasmine.createSpy('isSectionEnabled'); isSectionEnabled = jasmine.createSpy('isSectionEnabled');
isSectionReadOnly = jasmine.createSpy('isSectionReadOnly'); isSectionReadOnly = jasmine.createSpy('isSectionReadOnly');
isSectionAvailable = jasmine.createSpy('isSectionAvailable'); isSectionAvailable = jasmine.createSpy('isSectionAvailable');
isSectionTypeAvailable = jasmine.createSpy('isSectionTypeAvailable');
isSectionType = jasmine.createSpy('isSectionType');
addSection = jasmine.createSpy('addSection'); addSection = jasmine.createSpy('addSection');
removeSection = jasmine.createSpy('removeSection'); removeSection = jasmine.createSpy('removeSection');
updateSectionData = jasmine.createSpy('updateSectionData'); updateSectionData = jasmine.createSpy('updateSectionData');

View File

@@ -18,13 +18,13 @@
[uploader]="uploader" [uploader]="uploader"
(fileOver)="fileOverBase($event)" (fileOver)="fileOverBase($event)"
class="well ds-base-drop-zone mt-1 mb-3 text-muted"> class="well ds-base-drop-zone mt-1 mb-3 text-muted">
<p class="text-center m-0 p-0 d-flex justify-content-center align-items-center" *ngIf="uploader?.queue?.length === 0"> <div class="text-center m-0 p-2 d-flex justify-content-center align-items-center" *ngIf="uploader?.queue?.length === 0">
<span><i class="fas fa-cloud-upload" aria-hidden="true"></i> {{dropMsg | translate}} {{'uploader.or' | translate}}</span> <span><i class="fas fa-upload" aria-hidden="true"></i> {{dropMsg | translate}} {{'uploader.or' | translate}}</span>
<label class="btn btn-link m-0 p-0 ml-1"> <label for="inputFileUploader" class="btn btn-link m-0 p-0 ml-1" tabindex="0" (keyup.enter)="$event.stopImmediatePropagation(); fileInput.click()">
<input class="d-none" type="file" ng2FileSelect [uploader]="uploader" multiple /> <input #fileInput id="inputFileUploader" class="d-none" type="file" role="button" ng2FileSelect [uploader]="uploader" multiple tabindex="0" />
{{'uploader.browse' | translate}} {{'uploader.browse' | translate}}
</label> </label>
</p> </div>
<div *ngIf="(isOverBaseDropZone | async) || uploader?.queue?.length !== 0"> <div *ngIf="(isOverBaseDropZone | async) || uploader?.queue?.length !== 0">
<div class="m-1"> <div class="m-1">
<div class="upload-item-top"> <div class="upload-item-top">
@@ -32,7 +32,7 @@
<span *ngIf="!uploader.options.disableMultipart">{{'uploader.queue-length' | translate}}: {{ uploader?.queue?.length }} | </span>{{ uploader?.queue[0]?.file.name }} <span *ngIf="!uploader.options.disableMultipart">{{'uploader.queue-length' | translate}}: {{ uploader?.queue?.length }} | </span>{{ uploader?.queue[0]?.file.name }}
</span> </span>
<div class="btn-group btn-group-sm float-right" role="group"> <div class="btn-group btn-group-sm float-right" role="group">
<button type="button" class="btn btn-danger" (click)="uploader.clearQueue()" [disabled]="!uploader.queue.length"> <button type="button" class="btn btn-danger" title="{{'uploader.delete.btn-title' | translate}}" (click)="uploader.clearQueue()" [disabled]="!uploader.queue.length">
<i class="fas fa-trash" aria-hidden="true"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</div> </div>

View File

@@ -120,7 +120,7 @@ describe('SubmissionFormCollectionComponent Component', () => {
}); });
const sectionsService: any = jasmine.createSpyObj('sectionsService', { const sectionsService: any = jasmine.createSpyObj('sectionsService', {
isSectionAvailable: of(true) isSectionTypeAvailable: of(true)
}); });
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {

View File

@@ -28,6 +28,7 @@ import { CollectionDataService } from '../../../core/data/collection-data.servic
import { CollectionDropdownComponent } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { CollectionDropdownComponent } from '../../../shared/collection-dropdown/collection-dropdown.component';
import { SectionsService } from '../../sections/sections.service'; import { SectionsService } from '../../sections/sections.service';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { SectionsType } from '../../sections/sections-type';
/** /**
* This component allows to show the current collection the submission belonging to and to change it. * This component allows to show the current collection the submission belonging to and to change it.
@@ -142,7 +143,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit {
*/ */
ngOnInit() { ngOnInit() {
this.pathCombiner = new JsonPatchOperationPathCombiner('sections', 'collection'); this.pathCombiner = new JsonPatchOperationPathCombiner('sections', 'collection');
this.available$ = this.sectionsService.isSectionAvailable(this.submissionId, 'collection'); this.available$ = this.sectionsService.isSectionTypeAvailable(this.submissionId, SectionsType.collection);
} }
/** /**

View File

@@ -1,43 +1,52 @@
<div class="row" *ngIf="!!submissionId"> <div class="row" *ngIf="!!submissionId">
<div class="col"> <div class="col">
<!-- a class="btn btn-outline-primary" role="button" href="#"><i class="fas fa-times"></i> {{'submission.general.cancel' |translate}}</a -->
</div>
<div *ngIf="(processingSaveStatus | async) || (processingDepositStatus | async)" class="col d-flex justify-content-end align-items-center">
<div class="progress w-75">
<div *ngIf="(processingSaveStatus | async)" class="progress-bar progress-bar-striped progress-bar-animated bg-info" [style.width]="'100%'" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">Saving...</div>
<div *ngIf="(processingDepositStatus | async)" class="progress-bar progress-bar-striped progress-bar-animated bg-info" [style.width]="'100%'" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">Depositing...</div>
</div>
</div>
<div *ngIf="!(processingSaveStatus | async) && !(processingDepositStatus | async)" class="col text-right">
<button type="button"
class="btn btn-secondary"
id="save"
[disabled]="(processingSaveStatus | async) || !(hasUnsavedModification | async)"
(click)="save($event)">
<span><i class="fas fa-save"></i> {{'submission.general.save' | translate}}</span>
</button>
<button type="button"
[class.btn-primary]="!(showDepositAndDiscard | async)"
[class.btn-secondary]="(showDepositAndDiscard | async)"
class="btn"
id="saveForLater"
[disabled]="(processingSaveStatus | async)"
(click)="saveLater($event)">
<span><i class="fas fa-save"></i> {{'submission.general.save-later' | translate}}</span>
</button>
<button *ngIf="(showDepositAndDiscard | async)"
type="button"
class="btn btn-primary"
[disabled]="(submissionIsInvalid | async)" (click)="deposit($event)">
<span><i class="fas fa-plus"></i> {{'submission.general.deposit' | translate}}</span>
</button>
<button *ngIf="(showDepositAndDiscard | async)" <button *ngIf="(showDepositAndDiscard | async)"
type="button" type="button"
class="btn btn-danger" class="btn btn-danger"
[disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)"
(click)="$event.preventDefault();confirmDiscard(content)"> (click)="$event.preventDefault();confirmDiscard(content)">
<i class="fas fa-trash"></i> {{'submission.general.discard.submit' | translate}} <i class="fas fa-trash"></i> {{'submission.general.discard.submit' | translate}}
</button> </button>
</div> </div>
<div class="col text-right d-flex justify-content-end align-items-center">
<span *ngIf="!(hasUnsavedModification | async) && !(processingSaveStatus | async) && !(processingDepositStatus | async)">
<i class="fas fa-check-circle"></i> {{'submission.general.info.saved' | translate}}
</span>
<span *ngIf="(hasUnsavedModification | async) && !(processingSaveStatus | async) && !(processingDepositStatus | async)">
<i class="fas fa-exclamation-circle"></i> {{'submission.general.info.pending-changes' | translate}}
</span>
<div *ngIf="(processingSaveStatus | async) || (processingDepositStatus | async)" class="col d-flex justify-content-end align-items-center">
<div class="progress w-75">
<div *ngIf="(processingSaveStatus | async)" class="progress-bar progress-bar-striped progress-bar-animated bg-info" [style.width]="'100%'" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">Saving...</div>
<div *ngIf="(processingDepositStatus | async)" class="progress-bar progress-bar-striped progress-bar-animated bg-info" [style.width]="'100%'" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">Depositing...</div>
</div>
</div>
<div class="ml-2">
<button type="button"
class="btn btn-secondary"
id="save"
[disabled]="(processingSaveStatus | async) || !(hasUnsavedModification | async)"
(click)="save($event)">
<span><i class="fas fa-save"></i> {{'submission.general.save' | translate}}</span>
</button>
<button type="button"
[class.btn-primary]="!(showDepositAndDiscard | async)"
[class.btn-secondary]="(showDepositAndDiscard | async)"
class="btn"
id="saveForLater"
[disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)"
(click)="saveLater($event)">
<span><i class="fas fa-save"></i> {{'submission.general.save-later' | translate}}</span>
</button>
<button *ngIf="(showDepositAndDiscard | async)"
type="button"
class="btn btn-success"
[disabled]="(submissionIsInvalid | async) || (processingSaveStatus | async) || (processingDepositStatus | async)"
(click)="deposit($event)">
<span><i class="fas fa-plus"></i> {{'submission.general.deposit' | translate}}</span>
</button>
</div>
</div>
</div> </div>
<ng-template #content let-c="close" let-d="dismiss"> <ng-template #content let-c="close" let-d="dismiss">

View File

@@ -205,7 +205,7 @@ describe('SubmissionFormFooterComponent Component', () => {
comp.showDepositAndDiscard = observableOf(true); comp.showDepositAndDiscard = observableOf(true);
compAsAny.submissionIsInvalid = observableOf(true); compAsAny.submissionIsInvalid = observableOf(true);
fixture.detectChanges(); fixture.detectChanges();
const depositBtn: any = fixture.debugElement.query(By.css('.btn-primary')); const depositBtn: any = fixture.debugElement.query(By.css('.btn-success'));
expect(depositBtn.nativeElement.disabled).toBeTruthy(); expect(depositBtn.nativeElement.disabled).toBeTruthy();
}); });
@@ -214,7 +214,7 @@ describe('SubmissionFormFooterComponent Component', () => {
comp.showDepositAndDiscard = observableOf(true); comp.showDepositAndDiscard = observableOf(true);
compAsAny.submissionIsInvalid = observableOf(false); compAsAny.submissionIsInvalid = observableOf(false);
fixture.detectChanges(); fixture.detectChanges();
const depositBtn: any = fixture.debugElement.query(By.css('.btn-primary')); const depositBtn: any = fixture.debugElement.query(By.css('.btn-success'));
expect(depositBtn.nativeElement.disabled).toBeFalsy(); expect(depositBtn.nativeElement.disabled).toBeFalsy();
}); });

View File

@@ -3,7 +3,6 @@
<div *ngIf="(uploadEnabled$ | async)" class="w-100"> <div *ngIf="(uploadEnabled$ | async)" class="w-100">
<ds-submission-upload-files [submissionId]="submissionId" <ds-submission-upload-files [submissionId]="submissionId"
[collectionId]="collectionId" [collectionId]="collectionId"
[sectionId]="'upload'"
[uploadFilesOptions]="uploadFilesOptions"></ds-submission-upload-files> [uploadFilesOptions]="uploadFilesOptions"></ds-submission-upload-files>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>

View File

@@ -83,7 +83,6 @@ describe('SubmissionUploadFilesComponent Component', () => {
const html = ` const html = `
<ds-submission-upload-files [submissionId]="submissionId" <ds-submission-upload-files [submissionId]="submissionId"
[collectionId]="collectionId" [collectionId]="collectionId"
[sectionId]="'upload'"
[uploadFilesOptions]="uploadFilesOptions"></ds-submission-upload-files>`; [uploadFilesOptions]="uploadFilesOptions"></ds-submission-upload-files>`;
testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>; testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>;
@@ -108,11 +107,11 @@ describe('SubmissionUploadFilesComponent Component', () => {
compAsAny = comp; compAsAny = comp;
submissionServiceStub = TestBed.inject(SubmissionService as any); submissionServiceStub = TestBed.inject(SubmissionService as any);
sectionsServiceStub = TestBed.inject(SectionsService as any); sectionsServiceStub = TestBed.inject(SectionsService as any);
sectionsServiceStub.isSectionTypeAvailable.and.returnValue(observableOf(true));
notificationsServiceStub = TestBed.inject(NotificationsService as any); notificationsServiceStub = TestBed.inject(NotificationsService as any);
translateService = TestBed.inject(TranslateService); translateService = TestBed.inject(TranslateService);
comp.submissionId = submissionId; comp.submissionId = submissionId;
comp.collectionId = collectionId; comp.collectionId = collectionId;
comp.sectionId = 'upload';
comp.uploadFilesOptions = Object.assign(new UploaderOptions(),{ comp.uploadFilesOptions = Object.assign(new UploaderOptions(),{
url: '', url: '',
authToken: null, authToken: null,
@@ -133,7 +132,7 @@ describe('SubmissionUploadFilesComponent Component', () => {
}); });
it('should init uploadEnabled properly', () => { it('should init uploadEnabled properly', () => {
sectionsServiceStub.isSectionAvailable.and.returnValue(hot('-a-b', { sectionsServiceStub.isSectionTypeAvailable.and.returnValue(hot('-a-b', {
a: false, a: false,
b: true b: true
})); }));
@@ -149,55 +148,56 @@ describe('SubmissionUploadFilesComponent Component', () => {
expect(compAsAny.uploadEnabled).toBeObservable(expected); expect(compAsAny.uploadEnabled).toBeObservable(expected);
}); });
it('should show a success notification and call updateSectionData on upload complete', () => { describe('on upload complete', () => {
beforeEach(() => {
const expectedErrors: any = mockUploadResponse1ParsedErrors; sectionsServiceStub.isSectionType.and.callFake((_, sectionId, __) => observableOf(sectionId === 'upload'));
compAsAny.uploadEnabled = observableOf(true); compAsAny.uploadEnabled = observableOf(true);
fixture.detectChanges();
comp.onCompleteItem(Object.assign({}, uploadRestResponse, { sections: mockSectionsData }));
Object.keys(mockSectionsData).forEach((sectionId) => {
expect(sectionsServiceStub.updateSectionData).toHaveBeenCalledWith(
submissionId,
sectionId,
mockSectionsData[sectionId],
expectedErrors[sectionId],
expectedErrors[sectionId]
);
}); });
expect(notificationsServiceStub.success).toHaveBeenCalled(); it('should show a success notification and call updateSectionData if successful', () => {
const expectedErrors: any = mockUploadResponse1ParsedErrors;
fixture.detectChanges();
}); comp.onCompleteItem(Object.assign({}, uploadRestResponse, { sections: mockSectionsData }));
it('should show an error notification and call updateSectionData on upload complete', () => { Object.keys(mockSectionsData).forEach((sectionId) => {
expect(sectionsServiceStub.updateSectionData).toHaveBeenCalledWith(
const responseErrors = mockUploadResponse2Errors; submissionId,
sectionId,
const expectedErrors: any = mockUploadResponse2ParsedErrors; mockSectionsData[sectionId],
compAsAny.uploadEnabled = observableOf(true);
fixture.detectChanges();
comp.onCompleteItem(Object.assign({}, uploadRestResponse, {
sections: mockSectionsData,
errors: responseErrors.errors
}));
Object.keys(mockSectionsData).forEach((sectionId) => {
expect(sectionsServiceStub.updateSectionData).toHaveBeenCalledWith(
submissionId,
sectionId,
mockSectionsData[sectionId],
expectedErrors[sectionId], expectedErrors[sectionId],
expectedErrors[sectionId] expectedErrors[sectionId]
); );
});
expect(notificationsServiceStub.success).toHaveBeenCalled();
}); });
expect(notificationsServiceStub.success).not.toHaveBeenCalled(); it('should show an error notification and call updateSectionData if unsuccessful', () => {
const responseErrors = mockUploadResponse2Errors;
const expectedErrors: any = mockUploadResponse2ParsedErrors;
fixture.detectChanges();
comp.onCompleteItem(Object.assign({}, uploadRestResponse, {
sections: mockSectionsData,
errors: responseErrors.errors
}));
Object.keys(mockSectionsData).forEach((sectionId) => {
expect(sectionsServiceStub.updateSectionData).toHaveBeenCalledWith(
submissionId,
sectionId,
mockSectionsData[sectionId],
expectedErrors[sectionId],
expectedErrors[sectionId]
);
});
expect(notificationsServiceStub.success).not.toHaveBeenCalled();
});
}); });
}); });
}); });
@@ -210,7 +210,6 @@ class TestComponent {
submissionId = mockSubmissionId; submissionId = mockSubmissionId;
collectionId = mockSubmissionCollectionId; collectionId = mockSubmissionCollectionId;
sectionId = 'upload';
uploadFilesOptions = Object.assign(new UploaderOptions(), { uploadFilesOptions = Object.assign(new UploaderOptions(), {
url: '', url: '',
authToken: null, authToken: null,

View File

@@ -2,7 +2,7 @@ import { Component, Input, OnChanges } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Observable, of as observableOf, Subscription } from 'rxjs'; import { Observable, of as observableOf, Subscription } from 'rxjs';
import { first } from 'rxjs/operators'; import { first, take } from 'rxjs/operators';
import { SectionsService } from '../../sections/sections.service'; import { SectionsService } from '../../sections/sections.service';
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
@@ -13,6 +13,7 @@ import { UploaderOptions } from '../../../shared/uploader/uploader-options.model
import parseSectionErrors from '../../utils/parseSectionErrors'; import parseSectionErrors from '../../utils/parseSectionErrors';
import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service';
import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model';
import { SectionsType } from '../../sections/sections-type';
/** /**
* This component represents the drop zone that provides to add files to the submission. * This component represents the drop zone that provides to add files to the submission.
@@ -35,12 +36,6 @@ export class SubmissionUploadFilesComponent implements OnChanges {
*/ */
@Input() submissionId: string; @Input() submissionId: string;
/**
* The upload section id
* @type {string}
*/
@Input() sectionId: string;
/** /**
* The uploader configuration options * The uploader configuration options
* @type {UploaderOptions} * @type {UploaderOptions}
@@ -110,7 +105,7 @@ export class SubmissionUploadFilesComponent implements OnChanges {
* Check if upload functionality is enabled * Check if upload functionality is enabled
*/ */
ngOnChanges() { ngOnChanges() {
this.uploadEnabled = this.sectionService.isSectionAvailable(this.submissionId, this.sectionId); this.uploadEnabled = this.sectionService.isSectionTypeAvailable(this.submissionId, SectionsType.Upload);
} }
/** /**
@@ -136,14 +131,18 @@ export class SubmissionUploadFilesComponent implements OnChanges {
.forEach((sectionId) => { .forEach((sectionId) => {
const sectionData = normalizeSectionData(sections[sectionId]); const sectionData = normalizeSectionData(sections[sectionId]);
const sectionErrors = errorsList[sectionId]; const sectionErrors = errorsList[sectionId];
if (sectionId === 'upload') { this.sectionService.isSectionType(this.submissionId, sectionId, SectionsType.Upload)
// Look for errors on upload .pipe(take(1))
if ((isEmpty(sectionErrors))) { .subscribe((isUpload) => {
this.notificationsService.success(null, this.translate.get('submission.sections.upload.upload-successful')); if (isUpload) {
} else { // Look for errors on upload
this.notificationsService.error(null, this.translate.get('submission.sections.upload.upload-failed')); if ((isEmpty(sectionErrors))) {
} this.notificationsService.success(null, this.translate.get('submission.sections.upload.upload-successful'));
} } else {
this.notificationsService.error(null, this.translate.get('submission.sections.upload.upload-failed'));
}
}
});
this.sectionService.updateSectionData(this.submissionId, sectionId, sectionData, sectionErrors, sectionErrors); this.sectionService.updateSectionData(this.submissionId, sectionId, sectionData, sectionErrors, sectionErrors);
}); });
} }

View File

@@ -1,5 +1,5 @@
<div dsSection #sectionRef="sectionRef" <div dsSection #sectionRef="sectionRef"
[attr.id]="sectionData.id" [attr.id]="'section_' + sectionData.id"
[ngClass]="{ 'section-focus' : sectionRef.isSectionActive() }" [ngClass]="{ 'section-focus' : sectionRef.isSectionActive() }"
[mandatory]="sectionData.mandatory" [mandatory]="sectionData.mandatory"
[submissionId]="submissionId" [submissionId]="submissionId"
@@ -12,23 +12,25 @@
[destroyOnHide]="false"> [destroyOnHide]="false">
<ngb-panel id="{{ sectionData.id }}"> <ngb-panel id="{{ sectionData.id }}">
<ng-template ngbPanelTitle> <ng-template ngbPanelTitle>
<span class="float-left">{{ 'submission.sections.'+sectionData.header | translate }}</span> <span class="float-left section-title" tabindex="0">{{ 'submission.sections.'+sectionData.header | translate }}</span>
<div class="d-inline-block float-right"> <div class="d-inline-block float-right">
<i *ngIf="!(sectionRef.isValid() | async) && !(sectionRef.hasErrors())" class="fas fa-exclamation-circle text-warning mr-3" <i *ngIf="!(sectionRef.isValid() | async) && !(sectionRef.hasErrors())" class="fas fa-exclamation-circle text-warning mr-3"
aria-hidden="true"></i> aria-hidden="true" title="{{'submission.sections.status.warnings.title' | translate}}"></i>
<i *ngIf="(sectionRef.hasErrors())" class="fas fa-exclamation-circle text-danger mr-3" <i *ngIf="(sectionRef.hasErrors())" class="fas fa-exclamation-circle text-danger mr-3"
aria-hidden="true"></i> aria-hidden="true" title="{{'submission.sections.status.errors.title' | translate}}"></i>
<i *ngIf="(sectionRef.isValid() | async) && !(sectionRef.hasErrors())" class="fas fa-check-circle text-success mr-3" <i *ngIf="(sectionRef.isValid() | async) && !(sectionRef.hasErrors())" class="fas fa-check-circle text-success mr-3"
aria-hidden="true"></i> aria-hidden="true" title="{{'submission.sections.status.valid.title' | translate}}"></i>
<a class="close" aria-label="Close"> <a class="close"
<span *ngIf="sectionRef.isOpen()" class="fas fa-chevron-up fa-fw" tabindex="0"
aria-hidden="true"></span> role="button"
<span *ngIf="!sectionRef.isOpen()" class="fas fa-chevron-down fa-fw" [attr.aria-label]="(sectionRef.isOpen() ? 'submission.sections.toggle.close' : 'submission.sections.toggle.open') | translate"
aria-hidden="true"></span> [title]="(sectionRef.isOpen() ? 'submission.sections.toggle.close' : 'submission.sections.toggle.open') | translate">
<span *ngIf="sectionRef.isOpen()" class="fas fa-chevron-up fa-fw"></span>
<span *ngIf="!sectionRef.isOpen()" class="fas fa-chevron-down fa-fw"></span>
</a> </a>
<a href="#" class="close mr-3" *ngIf="!sectionRef.isMandatory()" <a href="#" class="close mr-3" *ngIf="!sectionRef.isMandatory()"
(click)="removeSection($event)"> (click)="removeSection($event)">
<i class="fas fa-trash-o" aria-hidden="true"></i> <i class="fas fa-trash-o" aria-hidden="true" tabindex="0"></i>
</a> </a>
</div> </div>
</ng-template> </ng-template>

View File

@@ -340,6 +340,38 @@ describe('SectionsService test suite', () => {
}); });
}); });
describe('isSectionType', () => {
it('should return true if the section matches the provided type', () => {
store.select.and.returnValue(observableOf(submissionState));
const expected = cold('(b|)', {
b: true
});
expect(service.isSectionType(submissionId, 'upload', SectionsType.Upload)).toBeObservable(expected);
});
it('should return false if the section doesn\'t match the provided type', () => {
store.select.and.returnValue(observableOf(submissionState));
const expected = cold('(b|)', {
b: false
});
expect(service.isSectionType(submissionId, sectionId, SectionsType.Upload)).toBeObservable(expected);
});
it('should return false if the provided sectionId doesn\'t exist', () => {
store.select.and.returnValue(observableOf(submissionState));
const expected = cold('(b|)', {
b: false
});
expect(service.isSectionType(submissionId, 'no-such-id', SectionsType.Upload)).toBeObservable(expected);
});
});
describe('addSection', () => { describe('addSection', () => {
it('should dispatch a new EnableSectionAction a move target to new section', () => { it('should dispatch a new EnableSectionAction a move target to new section', () => {

Some files were not shown because too many files have changed in this diff Show More