Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ bundle:
mediaViewer:
image: false
video: false
pdf: false

# Whether the end user agreement is required before users use the repository.
# If enabled, the user will be required to accept the agreement before they can use the repository.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<div class="pdf-viewer-container">

<div class="d-flex align-items-center mb-2">
<h2 class="simple-view-element-header mb-0 me-2">{{ 'media-viewer.pdf.files' | translate }}</h2>

<span class="badge rounded-pill bg-secondary" style="font-size: 0.9rem;">
{{ pdfs?.length || 0 }}
</span>
</div>

<select id="pdfSelect" class="form-select d-inline-block" [(ngModel)]="currentIndex" [disabled]="pdfs?.length === 1 || isLoading"
(change)="selectedMedia(currentIndex)">
@for (item of pdfs; track $index) {
<option [value]="$index">
{{ dsoNameService.getName(item.bitstream) }}
</option>
}
</select>

<div class="pdf-viewer border-bottom" style="position: relative; min-height: 200px;">

@if (isLoading) {
<div class="loading-overlay d-flex justify-content-center align-items-center h-100 w-100">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span class="ms-2">{{ 'media-viewer.loading' | translate }}...</span>
</div>
}

@else {
<object #pdfViewer [data]="blobUrl" type="application/pdf" width="100%" height="100%">
<p>
<span>{{ 'media-viewer.pdf.browser-not-support' | translate }}</span>
<a [href]="pdfs[currentIndex].bitstream._links.content.href">{{ 'media-viewer.pdf.download' | translate }}</a>.
</p>
</object>
}

</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.pdf-viewer-container {
margin-bottom: 2rem;
}

.simple-view-element-header {
font-size: 1.25rem;
}

.pdf-viewer {
height: 50vh;
padding-bottom: 1px;
min-height: 500px;
display: flex;
flex-direction: column;
}

.loading-overlay {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8f9fa;
color: #6c757d;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import {
ChangeDetectorRef,
NO_ERRORS_SCHEMA,
} from '@angular/core';
import {
ComponentFixture,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import {
By,
DomSanitizer,
} from '@angular/platform-browser';
import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service';
import { Bitstream } from '@dspace/core/shared/bitstream.model';
import { MediaViewerItem } from '@dspace/core/shared/media-viewer-item.model';
import { MockBitstreamFormat1 } from '@dspace/core/testing/item.mock';
import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock';
import {
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { of } from 'rxjs';

import { MediaViewerPdfComponent } from './media-viewer-pdf.component';

describe('MediaViewerPdfComponent', () => {
let component: MediaViewerPdfComponent;
let fixture: ComponentFixture<MediaViewerPdfComponent>;
let httpMock: HttpTestingController;
let sanitizer: DomSanitizer;

const mockBitstream: Bitstream = Object.assign(new Bitstream(), {
sizeBytes: 1024,
format: of(MockBitstreamFormat1),
_links: {
self: {
href: 'https://demo.dspace.org/api/core/bitstreams/123',
},
content: {
href: 'https://demo.dspace.org/api/core/bitstreams/123/content',
},
},
id: '123',
uuid: '123',
type: 'bitstream',
metadata: {
'dc.title': [
{ language: null, value: 'test_pdf.pdf' },
],
},
});

const mockMediaViewerItems: MediaViewerItem[] = [
{ bitstream: mockBitstream, format: 'application', mimetype: 'application/pdf', thumbnail: null, accessToken: null },
{ bitstream: mockBitstream, format: 'application', mimetype: 'application/pdf', thumbnail: null, accessToken: null },
];

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
FormsModule,
TranslateModule.forRoot({
loader: { provide: TranslateLoader, useClass: TranslateLoaderMock },
}),
MediaViewerPdfComponent,
],
providers: [
DSONameService,
ChangeDetectorRef,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(MediaViewerPdfComponent);
component = fixture.componentInstance;
sanitizer = TestBed.inject(DomSanitizer);
httpMock = TestBed.inject(HttpTestingController);
component.pdfs = mockMediaViewerItems;
fixture.detectChanges();
const initReq = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content');
initReq.flush(new Blob(['initial content'], { type: 'application/pdf' }));
});

afterEach(() => {
httpMock.verify();
});

it('should create', () => {
expect(component).toBeTruthy();
});

describe('ngOnInit', () => {
it('should call loadPdf with index 0', () => {
spyOn<any>(component, 'loadPdf');
component.ngOnInit();
// Dot notation cannot be used because 'loadPdf' is a private method.
// Accessing the private method only for testing purposes.
// eslint-disable-next-line @typescript-eslint/dot-notation
expect(component['loadPdf']).toHaveBeenCalledWith(0);
});
});

describe('loadPdf', () => {
it('should load and set blobUrl on success', () => {
const mockBlob = new Blob(['PDF content'], { type: 'application/pdf' });
const bypassSpy = spyOn(sanitizer, 'bypassSecurityTrustResourceUrl').and.callThrough();

component.ngOnInit();
const req = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content');
expect(req.request.method).toBe('GET');
req.flush(mockBlob);

expect(component.isLoading).toBeFalse();
expect(bypassSpy).toHaveBeenCalled();
expect(component.blobUrl).toBeTruthy();
});

it('should handle errors gracefully', () => {
spyOn(console, 'error');
component.ngOnInit();
const req = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content');
req.error(new ProgressEvent('Network error'));

expect(component.isLoading).toBeFalse();
expect(console.error).toHaveBeenCalled();
});
});

describe('selectedMedia', () => {
it('should set currentIndex and load the selected pdf', () => {
const spyLoad = spyOn<any>(component, 'loadPdf');
component.selectedMedia(1);
expect(component.currentIndex).toBe(1);
expect(spyLoad).toHaveBeenCalledWith(1);
});
});

describe('UI template', () => {
it('should display the select element with the correct number of options', () => {
fixture.detectChanges();
const selectEl = fixture.debugElement.query(By.css('select'));
expect(selectEl).toBeTruthy();

const options = selectEl.nativeElement.querySelectorAll('option');
expect(options.length).toBe(2);
});

it('should show loading overlay when isLoading is true', () => {
component.isLoading = true;
fixture.detectChanges();
const loadingEl = fixture.debugElement.query(By.css('.loading-overlay'));
expect(loadingEl).toBeTruthy();
});

it('should show pdf object when not loading', () => {
component.isLoading = false;
fixture.detectChanges();
const objectEl = fixture.debugElement.query(By.css('object'));
expect(objectEl).toBeTruthy();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { HttpClient } from '@angular/common/http';
import {
ChangeDetectorRef,
Component,
Input,
OnInit,
ViewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
DomSanitizer,
SafeResourceUrl,
} from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';

import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';

@Component({
selector: 'ds-base-media-viewer-pdf',
templateUrl: './media-viewer-pdf.component.html',
styleUrls: ['./media-viewer-pdf.component.scss'],
imports: [
FormsModule,
TranslateModule,
],
})
export class MediaViewerPdfComponent implements OnInit {
@Input() pdfs: MediaViewerItem[];
@ViewChild('pdfViewer') pdfViewer;

blobUrl: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl('');
currentIndex = 0;

isLoading = false;

constructor(private http: HttpClient, private sanitizer: DomSanitizer, public dsoNameService: DSONameService, private cdr: ChangeDetectorRef) { }

ngOnInit() {
this.loadPdf(this.currentIndex);
}

selectedMedia(index: number) {
this.currentIndex = index;
this.loadPdf(index);
}

private loadPdf(index: number) {
this.isLoading = true;

const url = this.pdfs[index].bitstream._links.content.href;

this.http.get(url, { responseType: 'blob' }).subscribe({
next: (blob) => {
const blobUrl = URL.createObjectURL(blob);
this.blobUrl = this.sanitizer.bypassSecurityTrustResourceUrl(blobUrl);

this.isLoading = false;
this.cdr.detectChanges();
},
error: (err: unknown) => {
console.error('Error loading PDF:', err);
this.isLoading = false;
this.cdr.detectChanges();
},
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
Component,
Input,
} from '@angular/core';

import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
import { ThemedComponent } from '../../../shared/theme-support/themed.component';
import { MediaViewerPdfComponent } from './media-viewer-pdf.component';

/**
* Themed wrapper for {@link MediaViewerPdfComponent}.
*/
@Component({
selector: 'ds-media-viewer-pdf',
styleUrls: [],
templateUrl: '../../../shared/theme-support/themed.component.html',
})
export class ThemedMediaViewerPdfComponent extends ThemedComponent<MediaViewerPdfComponent> {

@Input() pdfs: MediaViewerItem[];

protected inAndOutputNames: (keyof MediaViewerPdfComponent & keyof this)[] = [
'pdfs',
];

protected getComponentName(): string {
return 'MediaViewerPdfComponent';
}

protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../themes/${themeName}/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component`);
}

protected importUnthemedComponent(): Promise<any> {
return import('./media-viewer-pdf.component');
}

}
Loading
Loading