From fc7f300146e07c1bd9547e919e13d3fbe8090d6d Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 17 Apr 2026 14:32:23 +0300 Subject: [PATCH 1/7] open preview url on draft registry file click --- .../components/custom-step/custom-step.component.html | 1 + .../components/custom-step/custom-step.component.ts | 1 + .../components/files-control/files-control.component.ts | 8 ++++++++ 3 files changed, 10 insertions(+) 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..25bed2c5e 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 @@ -178,6 +178,7 @@

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

[filesLink]="filesLink()" [projectId]="projectId()" [provider]="provider()" + [draftId]="draftId()" (attachFile)="onAttachFile($event, q.responseKey!)" [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..4c8736206 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({}); 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..d6dee6616 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 @@ -10,6 +10,7 @@ import { filter, finalize, switchMap, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, output, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; import { CreateFolderDialogComponent } from '@osf/features/files/components'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; @@ -53,6 +54,7 @@ export class FilesControlComponent { projectId = input.required(); provider = input.required(); filesViewOnly = input(false); + draftId = input.required(); attachFile = output(); private readonly filesService = inject(FilesService); @@ -153,9 +155,15 @@ export class FilesControlComponent { }); } + private readonly router = inject(Router); + selectFile(file: FileModel): void { if (this.filesViewOnly()) return; this.attachFile.emit(file); + if (this.draftId() && file.guid) { + const url = this.router.serializeUrl(this.router.createUrlTree([this.draftId(), '/', 'files', file.guid])); + window.open(url, '_blank'); + } } onFileTreeSelected(file: FileModel): void { From 68ceae9726f6c9de200f85299cfc1712a1d84208 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 17 Apr 2026 16:43:44 +0300 Subject: [PATCH 2/7] add rendering page for draft registry files --- src/app/app.routes.ts | 7 ++ .../draft-file-detail.component.html | 25 ++++++ .../draft-file-detail.component.scss | 0 .../draft-file-detail.component.spec.ts | 22 +++++ .../draft-file-detail.component.ts | 80 +++++++++++++++++++ .../files-control/files-control.component.ts | 6 +- 6 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/app/features/files/pages/draft-file-detail/draft-file-detail.component.html create mode 100644 src/app/features/files/pages/draft-file-detail/draft-file-detail.component.scss create mode 100644 src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts create mode 100644 src/app/features/files/pages/draft-file-detail/draft-file-detail.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 711ff4387..6c108a66e 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -205,6 +205,13 @@ export const routes: Routes = [ loadComponent: () => import('./features/files/pages/file-redirect/file-redirect.component').then((m) => m.FileRedirectComponent), }, + { + path: 'registries/drafts/:id/files/:fileGuid', + loadComponent: () => + import('./features/files/pages/draft-file-detail/draft-file-detail.component').then( + (m) => m.DraftFileDetailComponent + ), + }, { path: ':id', canMatch: [isFileGuard], diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.html b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.html new file mode 100644 index 000000000..f678c6d28 --- /dev/null +++ b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.html @@ -0,0 +1,25 @@ + + +
+
+
+ @if (safeLink) { + + } + @if (isIframeLoading) { + + } +
+
+
diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.scss b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts new file mode 100644 index 000000000..c8c3c0115 --- /dev/null +++ b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DraftFileDetailComponent } from './draft-file-detail.component'; + +describe('DraftFileDetailComponent', () => { + let component: DraftFileDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DraftFileDetailComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DraftFileDetailComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.ts b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.ts new file mode 100644 index 000000000..9dc15fcf0 --- /dev/null +++ b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.ts @@ -0,0 +1,80 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { switchMap } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, 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 '@shared/components/loading-spinner/loading-spinner.component'; +import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; +import { ViewOnlyLinkHelperService } from '@shared/services/view-only-link-helper.service'; + +@Component({ + selector: 'osf-draft-file-detail.component', + imports: [SubHeaderComponent, LoadingSpinnerComponent], + templateUrl: './draft-file-detail.component.html', + styleUrl: './draft-file-detail.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DraftFileDetailComponent { + isFileLoading = select(FilesSelectors.isOpenedFileLoading); + file = select(FilesSelectors.getOpenedFile); + readonly router = inject(Router); + readonly route = inject(ActivatedRoute); + readonly sanitizer = inject(DomSanitizer); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); + isIframeLoading = true; + safeLink: SafeResourceUrl | null = null; + readonly destroyRef = inject(DestroyRef); + fileGuid = ''; + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + + private readonly actions = createDispatchMap({ + getFile: GetFile, + }); + + constructor() { + this.route.params + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((params) => { + this.fileGuid = params['fileGuid']; + return this.actions.getFile(this.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/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index d6dee6616..4625c0b15 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,7 +54,7 @@ export class FilesControlComponent { projectId = input.required(); provider = input.required(); filesViewOnly = input(false); - draftId = input.required(); + draftId = input(''); attachFile = output(); private readonly filesService = inject(FilesService); @@ -161,7 +161,9 @@ export class FilesControlComponent { if (this.filesViewOnly()) return; this.attachFile.emit(file); if (this.draftId() && file.guid) { - const url = this.router.serializeUrl(this.router.createUrlTree([this.draftId(), '/', 'files', file.guid])); + const url = this.router.serializeUrl( + this.router.createUrlTree(['registries', 'drafts', this.draftId(), 'files', file.guid]) + ); window.open(url, '_blank'); } } From cdfdebb94f27445707803422c59d59ac629bf615 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 17 Apr 2026 17:31:06 +0300 Subject: [PATCH 3/7] update tests --- .../draft-file-detail.component.spec.ts | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts index c8c3c0115..a6916ca17 100644 --- a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts +++ b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts @@ -1,4 +1,19 @@ +import { MockComponent, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +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'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +import { FilesSelectors } from '../../store'; import { DraftFileDetailComponent } from './draft-file-detail.component'; @@ -7,10 +22,29 @@ describe('DraftFileDetailComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { + const mockRoute: Partial = { + params: of({ fileGuid: 'test-file-guid' }), + queryParams: of({}), + }; + await TestBed.configureTestingModule({ - imports: [DraftFileDetailComponent], + imports: [DraftFileDetailComponent, MockComponent(SubHeaderComponent), MockComponent(LoadingSpinnerComponent)], + providers: [ + provideOSFCore(), + { provide: ActivatedRoute, useValue: mockRoute }, + MockProvider(Router, { url: '' }), + MockProvider(ViewOnlyLinkHelperService, { + hasViewOnlyParam: () => false, + getViewOnlyParam: () => null, + }), + provideMockStore({ + signals: [ + { selector: FilesSelectors.isOpenedFileLoading, value: false }, + { selector: FilesSelectors.getOpenedFile, value: null }, + ], + }), + ], }).compileComponents(); - fixture = TestBed.createComponent(DraftFileDetailComponent); component = fixture.componentInstance; await fixture.whenStable(); @@ -19,4 +53,23 @@ describe('DraftFileDetailComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set safeLink when getIframeLink is called with a valid render link', () => { + const mfrUrl = 'https://mfr.osf.io/render?url=https%3A%2F%2Fosf.io%2Fdownload%2Fch7jz%2F'; + component.file = signal({ links: { render: mfrUrl } }) as any; + component.getIframeLink(''); + expect(component.safeLink).not.toBeNull(); + }); + + it('should not set safeLink when file has no render link', () => { + component.file = signal({ links: {} }) as any; + component.safeLink = null; + component.getIframeLink(''); + expect(component.safeLink).toBeNull(); + }); + + it('should return null from getMfrUrlWithVersion when file has no render link', () => { + component.file = signal({ links: {} }) as any; + expect(component.getMfrUrlWithVersion('1')).toBeNull(); + }); }); From ea1d9a323bb4524bf40d3bc756e12078086e1039 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 20 Apr 2026 16:50:03 +0300 Subject: [PATCH 4/7] resolve CR --- .../draft-file-detail.component.spec.ts | 2 +- .../custom-step/custom-step.component.html | 2 +- .../custom-step/custom-step.component.ts | 9 +++++++++ .../files-control/files-control.component.html | 2 +- .../files-control/files-control.component.ts | 14 +++++--------- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts index a6916ca17..14b5a9e6c 100644 --- a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts +++ b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts @@ -44,7 +44,7 @@ describe('DraftFileDetailComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(DraftFileDetailComponent); component = fixture.componentInstance; await fixture.whenStable(); 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 25bed2c5e..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 @@ -178,8 +178,8 @@

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

[filesLink]="filesLink()" [projectId]="projectId()" [provider]="provider()" - [draftId]="draftId()" (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 4c8736206..8eb487380 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 @@ -136,6 +136,15 @@ export class CustomStepComponent implements OnDestroy { }); } + onOpenFile(file: FileModel): void { + if (this.draftId() && file.guid) { + const url = this.router.serializeUrl( + this.router.createUrlTree(['registries', 'drafts', this.draftId(), 'files', file.guid]) + ); + 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 4625c0b15..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 @@ -10,7 +10,6 @@ import { filter, finalize, switchMap, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, output, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; -import { Router } from '@angular/router'; import { CreateFolderDialogComponent } from '@osf/features/files/components'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; @@ -54,8 +53,8 @@ export class FilesControlComponent { projectId = input.required(); provider = input.required(); filesViewOnly = input(false); - draftId = input(''); attachFile = output(); + openFile = output(); private readonly filesService = inject(FilesService); private readonly customDialogService = inject(CustomDialogService); @@ -155,17 +154,14 @@ export class FilesControlComponent { }); } - private readonly router = inject(Router); + onEntryFileClicked(file: FileModel): void { + this.selectFile(file); + this.openFile.emit(file); + } selectFile(file: FileModel): void { if (this.filesViewOnly()) return; this.attachFile.emit(file); - if (this.draftId() && file.guid) { - const url = this.router.serializeUrl( - this.router.createUrlTree(['registries', 'drafts', this.draftId(), 'files', file.guid]) - ); - window.open(url, '_blank'); - } } onFileTreeSelected(file: FileModel): void { From 00a33724d8a0b9f733867d16d9f72e9e9f914de0 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 20 Apr 2026 21:20:57 +0300 Subject: [PATCH 5/7] resolve CR and update UI --- src/app/app.routes.ts | 12 +- .../draft-file-detail.component.scss | 0 .../draft-file-detail.component.spec.ts | 75 -------- .../file-preview/file-preview..component.scss | 10 ++ .../file-preview.component.html} | 8 +- .../file-preview.component.spec.ts | 166 ++++++++++++++++++ .../file-preview.component.ts} | 14 +- .../custom-step/custom-step.component.ts | 4 +- src/assets/i18n/en.json | 3 +- 9 files changed, 200 insertions(+), 92 deletions(-) delete mode 100644 src/app/features/files/pages/draft-file-detail/draft-file-detail.component.scss delete mode 100644 src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts create mode 100644 src/app/features/files/pages/file-preview/file-preview..component.scss rename src/app/features/files/pages/{draft-file-detail/draft-file-detail.component.html => file-preview/file-preview.component.html} (67%) create mode 100644 src/app/features/files/pages/file-preview/file-preview.component.spec.ts rename src/app/features/files/pages/{draft-file-detail/draft-file-detail.component.ts => file-preview/file-preview.component.ts} (87%) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 6c108a66e..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: () => @@ -205,13 +210,6 @@ export const routes: Routes = [ loadComponent: () => import('./features/files/pages/file-redirect/file-redirect.component').then((m) => m.FileRedirectComponent), }, - { - path: 'registries/drafts/:id/files/:fileGuid', - loadComponent: () => - import('./features/files/pages/draft-file-detail/draft-file-detail.component').then( - (m) => m.DraftFileDetailComponent - ), - }, { path: ':id', canMatch: [isFileGuard], diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.scss b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts b/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts deleted file mode 100644 index 14b5a9e6c..000000000 --- a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { of } from 'rxjs'; - -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; - -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'; - -import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; - -import { FilesSelectors } from '../../store'; - -import { DraftFileDetailComponent } from './draft-file-detail.component'; - -describe('DraftFileDetailComponent', () => { - let component: DraftFileDetailComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const mockRoute: Partial = { - params: of({ fileGuid: 'test-file-guid' }), - queryParams: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [DraftFileDetailComponent, MockComponent(SubHeaderComponent), MockComponent(LoadingSpinnerComponent)], - providers: [ - provideOSFCore(), - { provide: ActivatedRoute, useValue: mockRoute }, - MockProvider(Router, { url: '' }), - MockProvider(ViewOnlyLinkHelperService, { - hasViewOnlyParam: () => false, - getViewOnlyParam: () => null, - }), - provideMockStore({ - signals: [ - { selector: FilesSelectors.isOpenedFileLoading, value: false }, - { selector: FilesSelectors.getOpenedFile, value: null }, - ], - }), - ], - }); - fixture = TestBed.createComponent(DraftFileDetailComponent); - component = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set safeLink when getIframeLink is called with a valid render link', () => { - const mfrUrl = 'https://mfr.osf.io/render?url=https%3A%2F%2Fosf.io%2Fdownload%2Fch7jz%2F'; - component.file = signal({ links: { render: mfrUrl } }) as any; - component.getIframeLink(''); - expect(component.safeLink).not.toBeNull(); - }); - - it('should not set safeLink when file has no render link', () => { - component.file = signal({ links: {} }) as any; - component.safeLink = null; - component.getIframeLink(''); - expect(component.safeLink).toBeNull(); - }); - - it('should return null from getMfrUrlWithVersion when file has no render link', () => { - component.file = signal({ links: {} }) as any; - expect(component.getMfrUrlWithVersion('1')).toBeNull(); - }); -}); 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..2ecbf1c4e --- /dev/null +++ b/src/app/features/files/pages/file-preview/file-preview..component.scss @@ -0,0 +1,10 @@ +.metadata { + color: var(--dark-blue-1); + border: 1px solid var(--grey-2); + border-radius: 12px; +} + +.full-image { + min-height: 100vh; + min-width: 100%; +} diff --git a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.html b/src/app/features/files/pages/file-preview/file-preview.component.html similarity index 67% rename from src/app/features/files/pages/draft-file-detail/draft-file-detail.component.html rename to src/app/features/files/pages/file-preview/file-preview.component.html index f678c6d28..2402dacfe 100644 --- a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.html +++ b/src/app/features/files/pages/file-preview/file-preview.component.html @@ -1,5 +1,4 @@ -
@@ -21,5 +20,12 @@ }
+ +
+ +
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/draft-file-detail/draft-file-detail.component.ts b/src/app/features/files/pages/file-preview/file-preview.component.ts similarity index 87% rename from src/app/features/files/pages/draft-file-detail/draft-file-detail.component.ts rename to src/app/features/files/pages/file-preview/file-preview.component.ts index 9dc15fcf0..352a9fbd7 100644 --- a/src/app/features/files/pages/draft-file-detail/draft-file-detail.component.ts +++ b/src/app/features/files/pages/file-preview/file-preview.component.ts @@ -1,8 +1,10 @@ import { createDispatchMap, select } from '@ngxs/store'; +import { TranslatePipe } from '@ngx-translate/core'; + import { switchMap } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; +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'; @@ -14,12 +16,14 @@ import { ViewOnlyLinkHelperService } from '@shared/services/view-only-link-helpe @Component({ selector: 'osf-draft-file-detail.component', - imports: [SubHeaderComponent, LoadingSpinnerComponent], - templateUrl: './draft-file-detail.component.html', - styleUrl: './draft-file-detail.component.scss', + imports: [SubHeaderComponent, LoadingSpinnerComponent, TranslatePipe], + templateUrl: './file-preview.component.html', + styleUrl: './file-preview..component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DraftFileDetailComponent { +export class FilePreviewComponent { + @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; + isFileLoading = select(FilesSelectors.isOpenedFileLoading); file = select(FilesSelectors.getOpenedFile); readonly router = inject(Router); 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 8eb487380..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 @@ -138,9 +138,7 @@ export class CustomStepComponent implements OnDestroy { onOpenFile(file: FileModel): void { if (this.draftId() && file.guid) { - const url = this.router.serializeUrl( - this.router.createUrlTree(['registries', 'drafts', this.draftId(), 'files', file.guid]) - ); + const url = this.router.serializeUrl(this.router.createUrlTree([this.draftId(), 'files', file.guid, 'preview'])); window.open(url, '_blank'); } } 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" From 29c9361ffe0befd95db30dc6b49f0ed54b7d8dc1 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 21 Apr 2026 13:28:35 +0300 Subject: [PATCH 6/7] resolve CR comments --- .../files/pages/file-preview/file-preview..component.scss | 3 +-- .../files/pages/file-preview/file-preview.component.html | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) 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 index 2ecbf1c4e..4bd4ee39e 100644 --- a/src/app/features/files/pages/file-preview/file-preview..component.scss +++ b/src/app/features/files/pages/file-preview/file-preview..component.scss @@ -1,7 +1,6 @@ .metadata { - color: var(--dark-blue-1); border: 1px solid var(--grey-2); - border-radius: 12px; + border-radius: 0.75rem; } .full-image { 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 index 2402dacfe..2548a7650 100644 --- a/src/app/features/files/pages/file-preview/file-preview.component.html +++ b/src/app/features/files/pages/file-preview/file-preview.component.html @@ -23,8 +23,8 @@
From 2573a8151b5df7ab06875e8bdb19d3fb70293e84 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 21 Apr 2026 14:05:43 +0300 Subject: [PATCH 7/7] update code --- ...onent.scss => file-preview.component.scss} | 0 .../file-preview/file-preview.component.ts | 41 ++++++++----------- 2 files changed, 18 insertions(+), 23 deletions(-) rename src/app/features/files/pages/file-preview/{file-preview..component.scss => file-preview.component.scss} (100%) 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 similarity index 100% rename from src/app/features/files/pages/file-preview/file-preview..component.scss rename to src/app/features/files/pages/file-preview/file-preview.component.scss 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 index 352a9fbd7..ab9bbd904 100644 --- a/src/app/features/files/pages/file-preview/file-preview.component.ts +++ b/src/app/features/files/pages/file-preview/file-preview.component.ts @@ -10,48 +10,43 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { FilesSelectors, GetFile } from '@osf/features/files/store'; -import { LoadingSpinnerComponent } from '@shared/components/loading-spinner/loading-spinner.component'; -import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; -import { ViewOnlyLinkHelperService } from '@shared/services/view-only-link-helper.service'; +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.component', + selector: 'osf-draft-file-detail', imports: [SubHeaderComponent, LoadingSpinnerComponent, TranslatePipe], templateUrl: './file-preview.component.html', - styleUrl: './file-preview..component.scss', + styleUrl: './file-preview.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class FilePreviewComponent { @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; - isFileLoading = select(FilesSelectors.isOpenedFileLoading); - file = select(FilesSelectors.getOpenedFile); - readonly router = inject(Router); - readonly route = inject(ActivatedRoute); - readonly sanitizer = inject(DomSanitizer); + 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; - readonly destroyRef = inject(DestroyRef); - fileGuid = ''; - hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); - private readonly actions = createDispatchMap({ - getFile: GetFile, - }); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); constructor() { this.route.params .pipe( takeUntilDestroyed(this.destroyRef), - switchMap((params) => { - this.fileGuid = params['fileGuid']; - return this.actions.getFile(this.fileGuid); - }) + switchMap((params) => this.actions.getFile(params['fileGuid'])) ) - .subscribe(() => { - this.getIframeLink(''); - }); + .subscribe(() => this.getIframeLink('')); } getIframeLink(version: string) {