diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 711ff4387..a227f8e5a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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: () => diff --git a/src/app/features/files/pages/file-preview/file-preview.component.html b/src/app/features/files/pages/file-preview/file-preview.component.html new file mode 100644 index 000000000..2548a7650 --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview.component.html @@ -0,0 +1,31 @@ + +
+
+
+ @if (safeLink) { + + } + @if (isIframeLoading) { + + } +
+ +
+ +
+
+
diff --git a/src/app/features/files/pages/file-preview/file-preview.component.scss b/src/app/features/files/pages/file-preview/file-preview.component.scss new file mode 100644 index 000000000..4bd4ee39e --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview.component.scss @@ -0,0 +1,9 @@ +.metadata { + border: 1px solid var(--grey-2); + border-radius: 0.75rem; +} + +.full-image { + min-height: 100vh; + min-width: 100%; +} diff --git a/src/app/features/files/pages/file-preview/file-preview.component.spec.ts b/src/app/features/files/pages/file-preview/file-preview.component.spec.ts new file mode 100644 index 000000000..2ef809426 --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/src/app/features/files/pages/file-preview/file-preview.component.ts b/src/app/features/files/pages/file-preview/file-preview.component.ts new file mode 100644 index 000000000..ab9bbd904 --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview.component.ts @@ -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(); + } +} diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index 3424906dd..6bdc77c6e 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -179,6 +179,7 @@

{{ 'files.actions.uploadFile' | translate }}

[projectId]="projectId()" [provider]="provider()" (attachFile)="onAttachFile($event, q.responseKey!)" + (openFile)="onOpenFile($event)" [filesViewOnly]="filesViewOnly()" > diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 357bc71b5..7b5f066e6 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -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({}); @@ -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; diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html index 8d3350ae2..78c18d21c 100644 --- a/src/app/features/registries/components/files-control/files-control.component.html +++ b/src/app/features/registries/components/files-control/files-control.component.html @@ -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)" diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index 423a65d45..604141db5 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -54,6 +54,7 @@ export class FilesControlComponent { provider = input.required(); filesViewOnly = input(false); attachFile = output(); + openFile = output(); private readonly filesService = inject(FilesService); private readonly customDialogService = inject(CustomDialogService); @@ -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); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2887a045d..bcec6b3a1 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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"