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
5 changes: 5 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ export const routes: Routes = [
import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent),
data: { skipBreadcrumbs: true },
},
{
path: ':id/files/:fileGuid/preview',
loadComponent: () =>
import('./features/files/pages/file-preview/file-preview.component').then((m) => m.FilePreviewComponent),
},
{
path: 'spam-content',
loadComponent: () =>
Expand Down
Comment thread
nsemets marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<osf-sub-header [isLoading]="isFileLoading()" title="{{ file()?.name }}" />
<div class="flex gap-4 bg-white flex-column h-full flex-1 p-4 h-full">
<div class="flex flex-column lg:flex-row gap-4 flex-1 h-full">
<div class="w-full h-full lg:w-6">
@if (safeLink) {
<iframe
[src]="safeLink"
(load)="isIframeLoading = false"
[hidden]="isIframeLoading"
title="Rendering of document"
marginheight="0"
frameborder="0"
allowfullscreen=""
class="full-image"
height="100%"
width="100%"
></iframe>
}
@if (isIframeLoading) {
<osf-loading-spinner></osf-loading-spinner>
}
</div>

<div class="w-full flex flex-column gap-4 lg:w-6">
<div class="metadata p-4 flex flex-column gap-2">
<h2>{{ 'common.labels.metadata' | translate }}</h2>
<p>{{ 'files.detail.fileMetadata.previewNotAvailable' | translate }}</p>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.metadata {
border: 1px solid var(--grey-2);
border-radius: 0.75rem;
}

.full-image {
min-height: 100vh;
min-width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Store } from '@ngxs/store';

import { MockProvider } from 'ng-mocks';

import { Mock } from 'vitest';

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';

import { FileKind } from '@osf/shared/enums/file-kind.enum';
import { FileDetailsModel } from '@osf/shared/models/files/file.model';
import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model';
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';

import { provideOSFCore } from '@testing/osf.testing.provider';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
import {
BaseSetupOverrides,
mergeSignalOverrides,
provideMockStore,
SignalOverride,
} from '@testing/providers/store-provider.mock';
import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock';

import { FilesSelectors, GetFile } from '../../store';

import { FilePreviewComponent } from './file-preview.component';

interface SetupOverrides extends BaseSetupOverrides {
hasViewOnlyParam?: boolean;
viewOnlyParam?: string | null;
renderLink?: string;
}

describe('FilePreviewComponent', () => {
let component: FilePreviewComponent;
let fixture: ComponentFixture<FilePreviewComponent>;
let store: Store;
let mockRouter: RouterMockType;
let viewOnlyService: ViewOnlyLinkHelperMockType;

const encodedDownloadUrl = 'https://files.osf.io/v1/resources/abc/providers/osfstorage/file.txt';
const defaultRenderLink = `https://mfr.osf.io/render?url=${encodeURIComponent(encodedDownloadUrl)}`;

function buildFileDetailsModel(renderLink: string): FileDetailsModel {
return {
id: 'file-1',
guid: 'file-guid-1',
name: 'file.txt',
kind: FileKind.File,
path: '/file.txt',
size: 128,
materializedPath: '/file.txt',
dateModified: '2026-01-01T00:00:00.000Z',
dateCreated: '2026-01-01T00:00:00.000Z',
lastTouched: null,
tags: [],
currentVersion: 1,
showAsUnviewed: false,
extra: {
hashes: {
md5: 'md5',
sha256: 'sha256',
},
downloads: 1,
},
links: {
info: '',
move: '',
upload: '',
delete: '',
download: '',
render: renderLink,
html: '',
self: '',
},
target: {} as unknown as BaseNodeModel,
};
}

const defaultSignals: SignalOverride[] = [
{ selector: FilesSelectors.isOpenedFileLoading, value: false },
{ selector: FilesSelectors.getOpenedFile, value: buildFileDetailsModel(defaultRenderLink) },
];

function setup(overrides: SetupOverrides = {}) {
const route = ActivatedRouteMockBuilder.create()
.withParams(overrides.routeParams ?? { fileGuid: 'file-1' })
.build();
mockRouter = RouterMockBuilder.create().withUrl('/files/file-1/preview').build();
viewOnlyService = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnlyParam ?? false);
viewOnlyService.getViewOnlyParam = vi.fn().mockReturnValue(overrides.viewOnlyParam ?? null);

const signals = mergeSignalOverrides(defaultSignals, [
{
selector: FilesSelectors.getOpenedFile,
value: buildFileDetailsModel(overrides.renderLink ?? defaultRenderLink),
},
...(overrides.selectorOverrides ?? []),
]);

TestBed.configureTestingModule({
imports: [FilePreviewComponent],
providers: [
provideOSFCore(),
MockProvider(ActivatedRoute, route),
MockProvider(Router, mockRouter),
MockProvider(ViewOnlyLinkHelperService, viewOnlyService),
provideMockStore({ signals }),
],
});

store = TestBed.inject(Store);
fixture = TestBed.createComponent(FilePreviewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}

it('should create', () => {
setup();

expect(component).toBeTruthy();
});

it('should dispatch get file action with route file guid on init', () => {
setup();

expect(store.dispatch).toHaveBeenCalledWith(new GetFile('file-1'));
});

it('should keep mfr url unchanged when render link has no nested url param', () => {
setup({ renderLink: 'https://mfr.osf.io/render' });
(store.dispatch as Mock).mockClear();

const result = component.getMfrUrlWithVersion('2');

expect(result).toBe('https://mfr.osf.io/render');
expect(store.dispatch).not.toHaveBeenCalled();
});

it('should append version param to nested download url', () => {
setup();

const result = component.getMfrUrlWithVersion('3');

expect(result).toContain('https://mfr.osf.io/render?');
expect(result).toContain(encodeURIComponent('version=3'));
});

it('should append view only param when present', () => {
setup({ hasViewOnlyParam: true, viewOnlyParam: 'view-token-1' });

const result = component.getMfrUrlWithVersion();

expect(result).toContain(encodeURIComponent('view_only=view-token-1'));
});

it('should return null for empty render link', () => {
setup({ renderLink: '' });

const result = component.getMfrUrlWithVersion();

expect(result).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createDispatchMap, select } from '@ngxs/store';

import { TranslatePipe } from '@ngx-translate/core';

import { switchMap } from 'rxjs';

import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';

import { FilesSelectors, GetFile } from '@osf/features/files/store';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';

@Component({
selector: 'osf-draft-file-detail',
imports: [SubHeaderComponent, LoadingSpinnerComponent, TranslatePipe],
templateUrl: './file-preview.component.html',
styleUrl: './file-preview.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilePreviewComponent {
@HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full';

private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly sanitizer = inject(DomSanitizer);
private readonly destroyRef = inject(DestroyRef);
private readonly viewOnlyService = inject(ViewOnlyLinkHelperService);

private readonly actions = createDispatchMap({ getFile: GetFile });

file = select(FilesSelectors.getOpenedFile);
isFileLoading = select(FilesSelectors.isOpenedFileLoading);

isIframeLoading = true;
safeLink: SafeResourceUrl | null = null;

hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router));

constructor() {
this.route.params
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((params) => this.actions.getFile(params['fileGuid']))
)
.subscribe(() => this.getIframeLink(''));
}

getIframeLink(version: string) {
const url = this.getMfrUrlWithVersion(version);
if (url) {
this.safeLink = this.sanitizer.bypassSecurityTrustResourceUrl(url);
}
}

getMfrUrlWithVersion(version?: string): string | null {
const mfrUrl = this.file()?.links.render;
if (!mfrUrl) return null;
const mfrUrlObj = new URL(mfrUrl);
const encodedDownloadUrl = mfrUrlObj.searchParams.get('url');
if (!encodedDownloadUrl) return mfrUrl;

const downloadUrlObj = new URL(decodeURIComponent(encodedDownloadUrl));

if (version) downloadUrlObj.searchParams.set('version', version);

if (this.hasViewOnly()) {
const viewOnlyParam = this.viewOnlyService.getViewOnlyParam();
if (viewOnlyParam) downloadUrlObj.searchParams.set('view_only', viewOnlyParam);
}

mfrUrlObj.searchParams.set('url', downloadUrlObj.toString());

return mfrUrlObj.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ <h3 class="mb-2">{{ 'files.actions.uploadFile' | translate }}</h3>
[projectId]="projectId()"
[provider]="provider()"
(attachFile)="onAttachFile($event, q.responseKey!)"
(openFile)="onOpenFile($event)"
[filesViewOnly]="filesViewOnly()"
></osf-files-control>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class CustomStepComponent implements OnDestroy {
readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES;

step = signal(this.route.snapshot.params['step']);
draftId = signal(this.route.snapshot.params['id']);
currentPage = computed(() => this.pages()[this.step() - 1]);

stepForm: FormGroup = this.fb.group({});
Expand Down Expand Up @@ -135,6 +136,13 @@ export class CustomStepComponent implements OnDestroy {
});
}

onOpenFile(file: FileModel): void {
if (this.draftId() && file.guid) {
const url = this.router.serializeUrl(this.router.createUrlTree([this.draftId(), 'files', file.guid, 'preview']));
window.open(url, '_blank');
}
}

removeFromAttachedFiles(file: AttachedFile, questionKey: string): void {
if (!this.attachedFiles[questionKey]) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
[provider]="provider()"
[selectedFiles]="filesSelection"
(selectFile)="onFileTreeSelected($event)"
(entryFileClicked)="selectFile($event)"
(entryFileClicked)="onEntryFileClicked($event)"
(uploadFilesConfirmed)="uploadFiles($event)"
(loadFiles)="onLoadFiles($event)"
(setCurrentFolder)="setCurrentFolder($event)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class FilesControlComponent {
provider = input.required<string>();
filesViewOnly = input<boolean>(false);
attachFile = output<FileModel>();
openFile = output<FileModel>();

private readonly filesService = inject(FilesService);
private readonly customDialogService = inject(CustomDialogService);
Expand Down Expand Up @@ -153,6 +154,11 @@ export class FilesControlComponent {
});
}

onEntryFileClicked(file: FileModel): void {
this.selectFile(file);
this.openFile.emit(file);
}

selectFile(file: FileModel): void {
if (this.filesViewOnly()) return;
this.attachFile.emit(file);
Expand Down
3 changes: 2 additions & 1 deletion src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,8 @@
"resourceLanguage": "Resource Language",
"resourceType": "Resource Type"
},
"title": "File Metadata"
"title": "File Metadata",
"previewNotAvailable": "File or Registration metadata not available in preview mode."
},
"keywords": {
"title": "Keywords"
Expand Down
Loading