mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Merge pull request #1694 from atmire/w2p-92282_Fix-missing-auth-tokens-when-retrieving-Bitstreams
Support restricted thumbnails
This commit is contained in:
@@ -14,6 +14,14 @@ import { By } from '@angular/platform-browser';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths';
|
||||
import { LinkService } from '../../../../../core/cache/builders/link.service';
|
||||
import { AuthService } from '../../../../../core/auth/auth.service';
|
||||
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
|
||||
import { FileService } from '../../../../../core/shared/file.service';
|
||||
import { FileServiceStub } from '../../../../../shared/testing/file-service.stub';
|
||||
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub';
|
||||
import { ThemeService } from '../../../../../shared/theme-support/theme.service';
|
||||
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
|
||||
|
||||
describe('CollectionAdminSearchResultGridElementComponent', () => {
|
||||
let component: CollectionAdminSearchResultGridElementComponent;
|
||||
@@ -45,7 +53,11 @@ describe('CollectionAdminSearchResultGridElementComponent', () => {
|
||||
providers: [
|
||||
{ provide: TruncatableService, useValue: mockTruncatableService },
|
||||
{ provide: BitstreamDataService, useValue: {} },
|
||||
{ provide: LinkService, useValue: linkService }
|
||||
{ provide: LinkService, useValue: linkService },
|
||||
{ provide: AuthService, useClass: AuthServiceStub },
|
||||
{ provide: FileService, useClass: FileServiceStub },
|
||||
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
|
||||
{ provide: ThemeService, useValue: getMockThemeService() },
|
||||
]
|
||||
})
|
||||
.compileComponents();
|
||||
|
@@ -16,6 +16,14 @@ import { CommunitySearchResult } from '../../../../../shared/object-collection/s
|
||||
import { Community } from '../../../../../core/shared/community.model';
|
||||
import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths';
|
||||
import { LinkService } from '../../../../../core/cache/builders/link.service';
|
||||
import { AuthService } from '../../../../../core/auth/auth.service';
|
||||
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
|
||||
import { FileService } from '../../../../../core/shared/file.service';
|
||||
import { FileServiceStub } from '../../../../../shared/testing/file-service.stub';
|
||||
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub';
|
||||
import { ThemeService } from '../../../../../shared/theme-support/theme.service';
|
||||
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
|
||||
|
||||
describe('CommunityAdminSearchResultGridElementComponent', () => {
|
||||
let component: CommunityAdminSearchResultGridElementComponent;
|
||||
@@ -47,7 +55,11 @@ describe('CommunityAdminSearchResultGridElementComponent', () => {
|
||||
providers: [
|
||||
{ provide: TruncatableService, useValue: mockTruncatableService },
|
||||
{ provide: BitstreamDataService, useValue: {} },
|
||||
{ provide: LinkService, useValue: linkService }
|
||||
{ provide: LinkService, useValue: linkService },
|
||||
{ provide: AuthService, useClass: AuthServiceStub },
|
||||
{ provide: FileService, useClass: FileServiceStub },
|
||||
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
|
||||
{ provide: ThemeService, useValue: getMockThemeService() },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
})
|
||||
|
@@ -20,6 +20,12 @@ import { getMockThemeService } from '../../../../../shared/mocks/theme-service.m
|
||||
import { ThemeService } from '../../../../../shared/theme-support/theme.service';
|
||||
import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service';
|
||||
import { AccessStatusObject } from '../../../../../shared/object-list/access-status-badge/access-status.model';
|
||||
import { AuthService } from '../../../../../core/auth/auth.service';
|
||||
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
|
||||
import { FileService } from '../../../../../core/shared/file.service';
|
||||
import { FileServiceStub } from '../../../../../shared/testing/file-service.stub';
|
||||
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub';
|
||||
|
||||
describe('ItemAdminSearchResultGridElementComponent', () => {
|
||||
let component: ItemAdminSearchResultGridElementComponent;
|
||||
@@ -64,6 +70,9 @@ describe('ItemAdminSearchResultGridElementComponent', () => {
|
||||
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
|
||||
{ provide: ThemeService, useValue: mockThemeService },
|
||||
{ provide: AccessStatusDataService, useValue: mockAccessStatusDataService },
|
||||
{ provide: AuthService, useClass: AuthServiceStub },
|
||||
{ provide: FileService, useClass: FileServiceStub },
|
||||
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
})
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<div>
|
||||
<div *ngIf="!spinner">
|
||||
<label *ngIf="showMessage && message">{{ message }}</label>
|
||||
<div class="loader">
|
||||
<span class="l-1"></span>
|
||||
@@ -13,3 +13,6 @@
|
||||
<span class="l-10"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf='spinner' class="spinner spinner-border" role="status">
|
||||
<span class="sr-only">{{ message }}</span>
|
||||
</div>
|
||||
|
@@ -3,7 +3,7 @@
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ span[class*="l-"] {
|
||||
background: #000;
|
||||
display: inline-block;
|
||||
margin: 12px 2px;
|
||||
|
||||
|
||||
border-radius: 100%;
|
||||
-webkit-border-radius: 100%;
|
||||
-moz-border-radius: 100%;
|
||||
|
||||
|
||||
-webkit-animation: loader 2s infinite;
|
||||
-webkit-animation-timing-function: cubic-bezier(0.030, 0.615, 0.995, 0.415);
|
||||
-webkit-animation-fill-mode: both;
|
||||
@@ -71,3 +71,7 @@ span.l-10 {-webkit-animation-delay: 0s;animation-delay: 0s;-ms-animation-delay:
|
||||
50% {-ms-transform: translateX(30px); opacity: 0;}
|
||||
100% {opacity: 0;}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
color: var(--bs-gray-600);
|
||||
}
|
||||
|
@@ -15,6 +15,11 @@ export class LoadingComponent implements OnDestroy, OnInit {
|
||||
@Input() message: string;
|
||||
@Input() showMessage = true;
|
||||
|
||||
/**
|
||||
* Show a more compact spinner animation instead of the default one
|
||||
*/
|
||||
@Input() spinner = false;
|
||||
|
||||
private subscription: Subscription;
|
||||
|
||||
constructor(private translate: TranslateService) {
|
||||
|
@@ -15,8 +15,9 @@ export class ThemedLoadingComponent extends ThemedComponent<LoadingComponent> {
|
||||
|
||||
@Input() message: string;
|
||||
@Input() showMessage = true;
|
||||
@Input() spinner = false;
|
||||
|
||||
protected inAndOutputNames: (keyof LoadingComponent & keyof this)[] = ['message', 'showMessage'];
|
||||
protected inAndOutputNames: (keyof LoadingComponent & keyof this)[] = ['message', 'showMessage', 'spinner'];
|
||||
|
||||
constructor(
|
||||
protected resolver: ComponentFactoryResolver,
|
||||
|
8
src/app/shared/testing/authorization-service.stub.ts
Normal file
8
src/app/shared/testing/authorization-service.stub.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||
|
||||
export class AuthorizationDataServiceStub {
|
||||
isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string): Observable<boolean> {
|
||||
return observableOf(false);
|
||||
}
|
||||
}
|
7
src/app/shared/testing/file-service.stub.ts
Normal file
7
src/app/shared/testing/file-service.stub.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
export class FileServiceStub {
|
||||
retrieveFileDownloadLink() {
|
||||
return observableOf(null);
|
||||
}
|
||||
}
|
@@ -1,14 +1,21 @@
|
||||
<div class="thumbnail" [class.limit-width]="limitWidth">
|
||||
<ds-themed-loading *ngIf="isLoading; else showThumbnail" class="thumbnail-content" [showMessage]="false">
|
||||
text-content
|
||||
</ds-themed-loading>
|
||||
<ng-template #showThumbnail>
|
||||
<img *ngIf="src !== null" class="thumbnail-content img-fluid"
|
||||
[src]="src | dsSafeUrl" [alt]="alt | translate" (error)="errorHandler()">
|
||||
<div *ngIf="src === null" class="thumbnail-content outer">
|
||||
<div class="inner">
|
||||
<div class="thumbnail-placeholder w-100 h-100 lead">{{ placeholder | translate }}</div>
|
||||
<div class="thumbnail" [class.limit-width]="limitWidth" *ngVar="(isLoading$ | async) as isLoading">
|
||||
<div *ngIf="isLoading" class="thumbnail-content outer">
|
||||
<div class="inner">
|
||||
<div class="centered">
|
||||
<ds-themed-loading [spinner]="true"></ds-themed-loading>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<ng-container *ngVar="(src$ | async) as src">
|
||||
<!-- don't use *ngIf="!isLoading" so the thumbnail can load in while the animation is playing -->
|
||||
<img *ngIf="src !== null" class="thumbnail-content img-fluid" [ngClass]="{'d-none': isLoading}"
|
||||
[src]="src | dsSafeUrl" [alt]="alt | translate" (error)="errorHandler()" (load)="successHandler()">
|
||||
<div *ngIf="src === null && !isLoading" class="thumbnail-content outer">
|
||||
<div class="inner">
|
||||
<div class="thumbnail-placeholder centered lead">
|
||||
{{ placeholder | translate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
@@ -26,6 +26,10 @@ img {
|
||||
border: var(--ds-thumbnail-placeholder-border);
|
||||
color: var(--ds-thumbnail-placeholder-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
> .centered {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@@ -3,12 +3,15 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Bitstream } from '../core/shared/bitstream.model';
|
||||
import { SafeUrlPipe } from '../shared/utils/safe-url-pipe';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
import { ThumbnailComponent } from './thumbnail.component';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import {
|
||||
createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject,
|
||||
} from '../shared/remote-data.utils';
|
||||
import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../shared/remote-data.utils';
|
||||
import { AuthService } from '../core/auth/auth.service';
|
||||
import { FileService } from '../core/shared/file.service';
|
||||
import { VarDirective } from '../shared/utils/var.directive';
|
||||
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
|
||||
|
||||
// eslint-disable-next-line @angular-eslint/pipe-prefix
|
||||
@Pipe({ name: 'translate' })
|
||||
@@ -18,143 +21,311 @@ class MockTranslatePipe implements PipeTransform {
|
||||
}
|
||||
}
|
||||
|
||||
const CONTENT = 'content.url';
|
||||
|
||||
describe('ThumbnailComponent', () => {
|
||||
let comp: ThumbnailComponent;
|
||||
let fixture: ComponentFixture<ThumbnailComponent>;
|
||||
let de: DebugElement;
|
||||
let el: HTMLElement;
|
||||
let authService;
|
||||
let authorizationService;
|
||||
let fileService;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
authService = jasmine.createSpyObj('AuthService', {
|
||||
isAuthenticated: observableOf(true),
|
||||
});
|
||||
authorizationService = jasmine.createSpyObj('AuthorizationService', {
|
||||
isAuthorized: observableOf(true),
|
||||
});
|
||||
fileService = jasmine.createSpyObj('FileService', {
|
||||
retrieveFileDownloadLink: null
|
||||
});
|
||||
fileService.retrieveFileDownloadLink.and.callFake((url) => observableOf(`${url}?authentication-token=fake`));
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe],
|
||||
declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe, VarDirective],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||
{ provide: FileService, useValue: fileService }
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ThumbnailComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
authService = TestBed.inject(AuthService);
|
||||
|
||||
comp = fixture.componentInstance; // ThumbnailComponent test instance
|
||||
de = fixture.debugElement.query(By.css('div.thumbnail'));
|
||||
el = de.nativeElement;
|
||||
});
|
||||
|
||||
const withoutThumbnail = () => {
|
||||
describe('and there is a default image', () => {
|
||||
it('should display the default image', () => {
|
||||
comp.src = 'http://bit.stream';
|
||||
comp.defaultImage = 'http://default.img';
|
||||
comp.errorHandler();
|
||||
expect(comp.src).toBe(comp.defaultImage);
|
||||
});
|
||||
it('should include the alt text', () => {
|
||||
comp.src = 'http://bit.stream';
|
||||
comp.defaultImage = 'http://default.img';
|
||||
comp.errorHandler();
|
||||
comp.ngOnChanges();
|
||||
describe('loading', () => {
|
||||
it('should start out with isLoading$ true', () => {
|
||||
expect(comp.isLoading$.getValue()).toBeTrue();
|
||||
});
|
||||
|
||||
it('should set isLoading$ to false once an image is successfully loaded', () => {
|
||||
comp.setSrc('http://bit.stream');
|
||||
fixture.debugElement.query(By.css('img.thumbnail-content')).triggerEventHandler('load', new Event('load'));
|
||||
expect(comp.isLoading$.getValue()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should set isLoading$ to false once the src is set to null', () => {
|
||||
comp.setSrc(null);
|
||||
expect(comp.isLoading$.getValue()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should show a loading animation while isLoading$ is true', () => {
|
||||
expect(de.query(By.css('ds-themed-loading'))).toBeTruthy();
|
||||
|
||||
comp.isLoading$.next(false);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.debugElement.query(By.css('ds-themed-loading'))).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('with a thumbnail image', () => {
|
||||
beforeEach(() => {
|
||||
comp.src$.next('https://bit.stream');
|
||||
fixture.detectChanges();
|
||||
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
||||
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
|
||||
});
|
||||
|
||||
it('should render but hide the image while loading and show it once done', () => {
|
||||
let img = fixture.debugElement.query(By.css('img.thumbnail-content'));
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.classes['d-none']).toBeTrue();
|
||||
|
||||
comp.isLoading$.next(false);
|
||||
fixture.detectChanges();
|
||||
img = fixture.debugElement.query(By.css('img.thumbnail-content'));
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.classes['d-none']).toBeFalsy();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('without a thumbnail image', () => {
|
||||
beforeEach(() => {
|
||||
comp.src$.next(null);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should only show the HTML placeholder once done loading', () => {
|
||||
expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeFalsy();
|
||||
|
||||
comp.isLoading$.next(false);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe('and there is no default image', () => {
|
||||
it('should display the placeholder', () => {
|
||||
comp.src = 'http://default.img';
|
||||
comp.errorHandler();
|
||||
expect(comp.src).toBe(null);
|
||||
|
||||
comp.ngOnChanges();
|
||||
fixture.detectChanges();
|
||||
const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement;
|
||||
expect(placeholder.innerHTML).toBe('TRANSLATED ' + comp.placeholder);
|
||||
});
|
||||
|
||||
const errorHandler = () => {
|
||||
let fallbackSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
fallbackSpy = spyOn(comp, 'showFallback').and.callThrough();
|
||||
});
|
||||
|
||||
describe('retry with authentication token', () => {
|
||||
beforeEach(() => {
|
||||
// disconnect error handler to be sure it's only called once
|
||||
const img = fixture.debugElement.query(By.css('img.thumbnail-content'));
|
||||
img.nativeNode.onerror = null;
|
||||
});
|
||||
|
||||
it('should remember that it already retried once', () => {
|
||||
expect(comp.retriedWithToken).toBeFalse();
|
||||
comp.errorHandler();
|
||||
expect(comp.retriedWithToken).toBeTrue();
|
||||
});
|
||||
|
||||
describe('if not logged in', () => {
|
||||
beforeEach(() => {
|
||||
authService.isAuthenticated.and.returnValue(observableOf(false));
|
||||
});
|
||||
|
||||
it('should fall back to default', () => {
|
||||
comp.errorHandler();
|
||||
expect(fallbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('if logged in', () => {
|
||||
beforeEach(() => {
|
||||
authService.isAuthenticated.and.returnValue(observableOf(true));
|
||||
});
|
||||
|
||||
describe('and authorized to download the thumbnail', () => {
|
||||
beforeEach(() => {
|
||||
authorizationService.isAuthorized.and.returnValue(observableOf(true));
|
||||
});
|
||||
|
||||
it('should add an authentication token to the thumbnail URL', () => {
|
||||
comp.errorHandler();
|
||||
|
||||
if ((comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
|
||||
// If we failed to retrieve the Bitstream in the first place, fall back to the default
|
||||
expect(comp.src$.getValue()).toBe(null);
|
||||
expect(fallbackSpy).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(comp.src$.getValue()).toBe(CONTENT + '?authentication-token=fake');
|
||||
expect(fallbackSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('but not authorized to download the thumbnail', () => {
|
||||
beforeEach(() => {
|
||||
authorizationService.isAuthorized.and.returnValue(observableOf(false));
|
||||
});
|
||||
|
||||
it('should fall back to default', () => {
|
||||
comp.errorHandler();
|
||||
|
||||
expect(comp.src$.getValue()).toBe(null);
|
||||
expect(fallbackSpy).toHaveBeenCalled();
|
||||
|
||||
// We don't need to check authorization if we failed to retrieve the Bitstreamin the first place
|
||||
if (!(comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
|
||||
expect(authorizationService.isAuthorized).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('after retrying with token', () => {
|
||||
beforeEach(() => {
|
||||
comp.retriedWithToken = true;
|
||||
});
|
||||
|
||||
it('should fall back to default', () => {
|
||||
comp.errorHandler();
|
||||
expect(authService.isAuthenticated).not.toHaveBeenCalled();
|
||||
expect(fileService.retrieveFileDownloadLink).not.toHaveBeenCalled();
|
||||
expect(fallbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('fallback', () => {
|
||||
describe('if there is a default image', () => {
|
||||
it('should display the default image', () => {
|
||||
comp.src$.next('http://bit.stream');
|
||||
comp.defaultImage = 'http://default.img';
|
||||
comp.errorHandler();
|
||||
expect(comp.src$.getValue()).toBe(comp.defaultImage);
|
||||
});
|
||||
|
||||
it('should include the alt text', () => {
|
||||
comp.src$.next('http://bit.stream');
|
||||
comp.defaultImage = 'http://default.img';
|
||||
comp.errorHandler();
|
||||
|
||||
fixture.detectChanges();
|
||||
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
|
||||
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('if there is no default image', () => {
|
||||
it('should display the HTML placeholder', () => {
|
||||
comp.src$.next('http://default.img');
|
||||
comp.defaultImage = null;
|
||||
comp.errorHandler();
|
||||
expect(comp.src$.getValue()).toBe(null);
|
||||
|
||||
fixture.detectChanges();
|
||||
const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement;
|
||||
expect(placeholder.innerHTML).toContain('TRANSLATED ' + comp.placeholder);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with thumbnail as Bitstream', () => {
|
||||
let thumbnail: Bitstream;
|
||||
let thumbnail;
|
||||
beforeEach(() => {
|
||||
thumbnail = new Bitstream();
|
||||
thumbnail._links = {
|
||||
self: { href: 'self.url' },
|
||||
bundle: { href: 'bundle.url' },
|
||||
format: { href: 'format.url' },
|
||||
content: { href: 'content.url' },
|
||||
content: { href: CONTENT },
|
||||
thumbnail: undefined,
|
||||
};
|
||||
comp.thumbnail = thumbnail;
|
||||
});
|
||||
|
||||
it('should display an image', () => {
|
||||
comp.thumbnail = thumbnail;
|
||||
comp.ngOnChanges();
|
||||
fixture.detectChanges();
|
||||
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
||||
expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href);
|
||||
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
|
||||
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
|
||||
});
|
||||
|
||||
it('should include the alt text', () => {
|
||||
comp.thumbnail = thumbnail;
|
||||
comp.ngOnChanges();
|
||||
fixture.detectChanges();
|
||||
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
||||
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
|
||||
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
|
||||
});
|
||||
|
||||
describe('when there is no thumbnail', () => {
|
||||
withoutThumbnail();
|
||||
errorHandler();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with thumbnail as RemoteData<Bitstream>', () => {
|
||||
let thumbnail: RemoteData<Bitstream>;
|
||||
let thumbnail: Bitstream;
|
||||
|
||||
describe('while loading', () => {
|
||||
beforeEach(() => {
|
||||
thumbnail = createPendingRemoteDataObject();
|
||||
});
|
||||
|
||||
it('should show a loading animation', () => {
|
||||
comp.thumbnail = thumbnail;
|
||||
comp.ngOnChanges();
|
||||
fixture.detectChanges();
|
||||
expect(de.query(By.css('ds-themed-loading'))).toBeTruthy();
|
||||
});
|
||||
beforeEach(() => {
|
||||
thumbnail = new Bitstream();
|
||||
thumbnail._links = {
|
||||
self: { href: 'self.url' },
|
||||
bundle: { href: 'bundle.url' },
|
||||
format: { href: 'format.url' },
|
||||
content: { href: CONTENT },
|
||||
thumbnail: undefined
|
||||
};
|
||||
});
|
||||
|
||||
describe('when there is a thumbnail', () => {
|
||||
beforeEach(() => {
|
||||
const bitstream = new Bitstream();
|
||||
bitstream._links = {
|
||||
self: { href: 'self.url' },
|
||||
bundle: { href: 'bundle.url' },
|
||||
format: { href: 'format.url' },
|
||||
content: { href: 'content.url' },
|
||||
thumbnail: undefined,
|
||||
};
|
||||
thumbnail = createSuccessfulRemoteDataObject(bitstream);
|
||||
comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail);
|
||||
});
|
||||
|
||||
it('should display an image', () => {
|
||||
comp.thumbnail = thumbnail;
|
||||
comp.ngOnChanges();
|
||||
fixture.detectChanges();
|
||||
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
||||
expect(image.getAttribute('src')).toBe(comp.thumbnail.payload._links.content.href);
|
||||
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
|
||||
});
|
||||
|
||||
it('should display the alt text', () => {
|
||||
comp.thumbnail = thumbnail;
|
||||
comp.ngOnChanges();
|
||||
fixture.detectChanges();
|
||||
const image: HTMLElement = de.query(By.css('img')).nativeElement;
|
||||
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
|
||||
});
|
||||
|
||||
describe('but it can\'t be loaded', () => {
|
||||
errorHandler();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is no thumbnail', () => {
|
||||
beforeEach(() => {
|
||||
thumbnail = createFailedRemoteDataObject();
|
||||
comp.thumbnail = createFailedRemoteDataObject();
|
||||
});
|
||||
|
||||
withoutThumbnail();
|
||||
errorHandler();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,7 +1,13 @@
|
||||
import { Component, Input, OnChanges } from '@angular/core';
|
||||
import { Bitstream } from '../core/shared/bitstream.model';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { hasNoValue, hasValue } from '../shared/empty.util';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { BehaviorSubject, of as observableOf } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { FeatureID } from '../core/data/feature-authorization/feature-id';
|
||||
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
|
||||
import { AuthService } from '../core/auth/auth.service';
|
||||
import { FileService } from '../core/shared/file.service';
|
||||
|
||||
/**
|
||||
* This component renders a given Bitstream as a thumbnail.
|
||||
@@ -14,7 +20,6 @@ import { RemoteData } from '../core/data/remote-data';
|
||||
templateUrl: './thumbnail.component.html',
|
||||
})
|
||||
export class ThumbnailComponent implements OnChanges {
|
||||
|
||||
/**
|
||||
* The thumbnail Bitstream
|
||||
*/
|
||||
@@ -29,7 +34,9 @@ export class ThumbnailComponent implements OnChanges {
|
||||
/**
|
||||
* The src attribute used in the template to render the image.
|
||||
*/
|
||||
src: string = null;
|
||||
src$ = new BehaviorSubject<string>(undefined);
|
||||
|
||||
retriedWithToken = false;
|
||||
|
||||
/**
|
||||
* i18n key of thumbnail alt text
|
||||
@@ -46,50 +53,123 @@ export class ThumbnailComponent implements OnChanges {
|
||||
*/
|
||||
@Input() limitWidth? = true;
|
||||
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* Whether the thumbnail is currently loading
|
||||
* Start out as true to avoid flashing the alt text while a thumbnail is being loaded.
|
||||
*/
|
||||
isLoading$ = new BehaviorSubject(true);
|
||||
|
||||
constructor(
|
||||
protected auth: AuthService,
|
||||
protected authorizationService: AuthorizationDataService,
|
||||
protected fileService: FileService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the thumbnail.
|
||||
* Use a default image if no actual image is available.
|
||||
*/
|
||||
ngOnChanges(): void {
|
||||
if (this.thumbnail === undefined || this.thumbnail === null) {
|
||||
if (hasNoValue(this.thumbnail)) {
|
||||
return;
|
||||
}
|
||||
if (this.thumbnail instanceof Bitstream) {
|
||||
this.resolveThumbnail(this.thumbnail as Bitstream);
|
||||
|
||||
const thumbnail = this.bitstream;
|
||||
if (hasValue(thumbnail?._links?.content?.href)) {
|
||||
this.setSrc(thumbnail?._links?.content?.href);
|
||||
} else {
|
||||
const thumbnailRD = this.thumbnail as RemoteData<Bitstream>;
|
||||
if (thumbnailRD.isLoading) {
|
||||
this.isLoading = true;
|
||||
} else {
|
||||
this.resolveThumbnail(thumbnailRD.payload as Bitstream);
|
||||
}
|
||||
this.showFallback();
|
||||
}
|
||||
}
|
||||
|
||||
private resolveThumbnail(thumbnail: Bitstream): void {
|
||||
if (hasValue(thumbnail) && hasValue(thumbnail._links)
|
||||
&& hasValue(thumbnail._links.content)
|
||||
&& thumbnail._links.content.href) {
|
||||
this.src = thumbnail._links.content.href;
|
||||
} else {
|
||||
this.src = this.defaultImage;
|
||||
/**
|
||||
* The current thumbnail Bitstream
|
||||
* @private
|
||||
*/
|
||||
private get bitstream(): Bitstream {
|
||||
if (this.thumbnail instanceof Bitstream) {
|
||||
return this.thumbnail as Bitstream;
|
||||
} else if (this.thumbnail instanceof RemoteData) {
|
||||
return (this.thumbnail as RemoteData<Bitstream>).payload;
|
||||
}
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle image download errors.
|
||||
* If the image can't be found, use the defaultImage instead.
|
||||
* If that also can't be found, use null to fall back to the HTML placeholder.
|
||||
* If the image can't be loaded, try re-requesting it with an authorization token in case it's a restricted Bitstream
|
||||
* Otherwise, fall back to the default image or a HTML placeholder
|
||||
*/
|
||||
errorHandler() {
|
||||
if (this.src !== this.defaultImage) {
|
||||
this.src = this.defaultImage;
|
||||
if (!this.retriedWithToken && hasValue(this.thumbnail)) {
|
||||
// the thumbnail may have failed to load because it's restricted
|
||||
// → retry with an authorization token
|
||||
// only do this once; fall back to the default if it still fails
|
||||
this.retriedWithToken = true;
|
||||
|
||||
const thumbnail = this.bitstream;
|
||||
this.auth.isAuthenticated().pipe(
|
||||
switchMap((isLoggedIn) => {
|
||||
if (isLoggedIn && hasValue(thumbnail)) {
|
||||
return this.authorizationService.isAuthorized(FeatureID.CanDownload, thumbnail.self);
|
||||
} else {
|
||||
return observableOf(false);
|
||||
}
|
||||
}),
|
||||
switchMap((isAuthorized) => {
|
||||
if (isAuthorized) {
|
||||
return this.fileService.retrieveFileDownloadLink(thumbnail._links.content.href);
|
||||
} else {
|
||||
return observableOf(null);
|
||||
}
|
||||
})
|
||||
).subscribe((url: string) => {
|
||||
if (hasValue(url)) {
|
||||
// If we got a URL, try to load it
|
||||
// (if it still fails this method will be called again, and we'll fall back to the default)
|
||||
// Otherwise, fall back to the default image right now
|
||||
this.setSrc(url);
|
||||
} else {
|
||||
this.showFallback();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.src = null;
|
||||
this.showFallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* To be called when the requested thumbnail could not be found
|
||||
* - If the current src is not the default image, try that first
|
||||
* - If this was already the case and the default image could not be found either,
|
||||
* show an HTML placecholder by setting src to null
|
||||
*
|
||||
* Also stops the loading animation.
|
||||
*/
|
||||
showFallback() {
|
||||
if (this.src$.getValue() !== this.defaultImage) {
|
||||
this.setSrc(this.defaultImage);
|
||||
} else {
|
||||
this.setSrc(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the thumbnail.
|
||||
* Stop the loading animation if setting to null.
|
||||
* @param src
|
||||
*/
|
||||
setSrc(src: string): void {
|
||||
this.src$.next(src);
|
||||
if (src === null) {
|
||||
this.isLoading$.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the loading animation once the thumbnail is successfully loaded
|
||||
*/
|
||||
successHandler() {
|
||||
this.isLoading$.next(false);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user