Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
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();
}
}
4 changes: 2 additions & 2 deletions src/app/features/files/pages/files/files.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,8 +619,8 @@ export class FilesComponent {
this.actions.setMoveDialogCurrentFolder(folder);
}

deleteEntry(link: string) {
this.actions.deleteEntry(link).subscribe(() => {
deleteEntry(file: FileModel): void {
this.actions.deleteEntry(file?.links.delete).subscribe(() => {
this.toastService.showSuccess('files.dialogs.deleteFile.success');
this.updateFilesList();
});
Expand Down
Comment thread
nsemets marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ <h3 class="mb-2">{{ 'files.actions.uploadFile' | translate }}</h3>
[projectId]="projectId()"
[provider]="provider()"
(attachFile)="onAttachFile($event, q.responseKey!)"
(openFile)="onOpenFile($event)"
[filesViewOnly]="filesViewOnly()"
[isDraftResource]="true"
Comment thread
nsemets marked this conversation as resolved.
Outdated
(removeFromAttachedFiles)="removeFromAttachedFiles($event, 'uploader')"
Comment thread
nsemets marked this conversation as resolved.
Outdated
></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 {
Comment thread
nsemets marked this conversation as resolved.
Outdated
if (!this.attachedFiles[questionKey]) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,11 @@
[resourceId]="projectId()"
[provider]="provider()"
[selectedFiles]="filesSelection"
[isDraftResource]="isDraftResource()"
(deleteEntryAction)="deleteEntry($event)"
(removeFromAttachedFiles)="onRemoveFromAttachedFiles($event)"
Comment thread
nsemets marked this conversation as resolved.
Outdated
(selectFile)="onFileTreeSelected($event)"
(entryFileClicked)="selectFile($event)"
(entryFileClicked)="onEntryFileClicked($event)"
(uploadFilesConfirmed)="uploadFiles($event)"
(loadFiles)="onLoadFiles($event)"
(setCurrentFolder)="setCurrentFolder($event)"
Expand Down
Loading
Loading