diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts
index 2cb0413bbc..1ea27b36b6 100644
--- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts
+++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts
@@ -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();
diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts
index 17ce2cd7a1..996366e20a 100644
--- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts
+++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts
@@ -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]
})
diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts
index a6ea7e4946..e478aa3ef3 100644
--- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts
+++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts
@@ -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]
})
diff --git a/src/app/shared/loading/loading.component.html b/src/app/shared/loading/loading.component.html
index efec4e597e..bb407098d7 100644
--- a/src/app/shared/loading/loading.component.html
+++ b/src/app/shared/loading/loading.component.html
@@ -1,4 +1,4 @@
-
+
@@ -13,3 +13,6 @@
+
+ {{ message }}
+
diff --git a/src/app/shared/loading/loading.component.scss b/src/app/shared/loading/loading.component.scss
index e2287cdc8b..a86778c9b5 100644
--- a/src/app/shared/loading/loading.component.scss
+++ b/src/app/shared/loading/loading.component.scss
@@ -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);
+}
diff --git a/src/app/shared/loading/loading.component.ts b/src/app/shared/loading/loading.component.ts
index d617d8c7a4..e64d89620b 100644
--- a/src/app/shared/loading/loading.component.ts
+++ b/src/app/shared/loading/loading.component.ts
@@ -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) {
diff --git a/src/app/shared/loading/themed-loading.component.ts b/src/app/shared/loading/themed-loading.component.ts
index 0f887a025f..ffdf9d3cbe 100644
--- a/src/app/shared/loading/themed-loading.component.ts
+++ b/src/app/shared/loading/themed-loading.component.ts
@@ -15,8 +15,9 @@ export class ThemedLoadingComponent extends ThemedComponent
{
@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,
diff --git a/src/app/shared/testing/authorization-service.stub.ts b/src/app/shared/testing/authorization-service.stub.ts
new file mode 100644
index 0000000000..253599233e
--- /dev/null
+++ b/src/app/shared/testing/authorization-service.stub.ts
@@ -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 {
+ return observableOf(false);
+ }
+}
diff --git a/src/app/shared/testing/file-service.stub.ts b/src/app/shared/testing/file-service.stub.ts
new file mode 100644
index 0000000000..c675df83e1
--- /dev/null
+++ b/src/app/shared/testing/file-service.stub.ts
@@ -0,0 +1,7 @@
+import { of as observableOf } from 'rxjs';
+
+export class FileServiceStub {
+ retrieveFileDownloadLink() {
+ return observableOf(null);
+ }
+}
diff --git a/src/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html
index e73ad77ce5..6a0516b0d4 100644
--- a/src/app/thumbnail/thumbnail.component.html
+++ b/src/app/thumbnail/thumbnail.component.html
@@ -1,14 +1,21 @@
-
-
- text-content
-
-
-
-
-
-
{{ placeholder | translate }}
+
+
+
+
+
+
+
+
+ {{ placeholder | translate }}
+
+
+
+
diff --git a/src/app/thumbnail/thumbnail.component.scss b/src/app/thumbnail/thumbnail.component.scss
index b15238afac..e9cb1a6cb5 100644
--- a/src/app/thumbnail/thumbnail.component.scss
+++ b/src/app/thumbnail/thumbnail.component.scss
@@ -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;
diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts
index 11271ebeb4..5b313d61d4 100644
--- a/src/app/thumbnail/thumbnail.component.spec.ts
+++ b/src/app/thumbnail/thumbnail.component.spec.ts
@@ -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
;
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)?.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)?.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', () => {
- let thumbnail: RemoteData;
+ 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();
});
});
});
diff --git a/src/app/thumbnail/thumbnail.component.ts b/src/app/thumbnail/thumbnail.component.ts
index b47beb22d4..ac0992c345 100644
--- a/src/app/thumbnail/thumbnail.component.ts
+++ b/src/app/thumbnail/thumbnail.component.ts
@@ -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(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;
- 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).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);
+ }
}