Skip to content

Commit 100d23f

Browse files
authored
[ENG-9958] Files in draft registrations cannot be previewed (#957)
- Ticket: https://openscience.atlassian.net/browse/ENG-9958 - Feature flag: n/a ## Purpose Prior to the Angular release, files attached to draft registrations could be previewed directly within the registration. This functionality no longer exists in Angular. ## Summary of Changes open preview url on draft registry file click
1 parent cdbfda5 commit 100d23f

10 files changed

Lines changed: 308 additions & 2 deletions

File tree

src/app/app.routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ export const routes: Routes = [
182182
import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent),
183183
data: { skipBreadcrumbs: true },
184184
},
185+
{
186+
path: ':id/files/:fileGuid/preview',
187+
loadComponent: () =>
188+
import('./features/files/pages/file-preview/file-preview.component').then((m) => m.FilePreviewComponent),
189+
},
185190
{
186191
path: 'spam-content',
187192
loadComponent: () =>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<osf-sub-header [isLoading]="isFileLoading()" title="{{ file()?.name }}" />
2+
<div class="flex gap-4 bg-white flex-column h-full flex-1 p-4 h-full">
3+
<div class="flex flex-column lg:flex-row gap-4 flex-1 h-full">
4+
<div class="w-full h-full lg:w-6">
5+
@if (safeLink) {
6+
<iframe
7+
[src]="safeLink"
8+
(load)="isIframeLoading = false"
9+
[hidden]="isIframeLoading"
10+
title="Rendering of document"
11+
marginheight="0"
12+
frameborder="0"
13+
allowfullscreen=""
14+
class="full-image"
15+
height="100%"
16+
width="100%"
17+
></iframe>
18+
}
19+
@if (isIframeLoading) {
20+
<osf-loading-spinner></osf-loading-spinner>
21+
}
22+
</div>
23+
24+
<div class="w-full flex flex-column gap-4 lg:w-6">
25+
<div class="metadata p-4 flex flex-column gap-2">
26+
<h2>{{ 'common.labels.metadata' | translate }}</h2>
27+
<p>{{ 'files.detail.fileMetadata.previewNotAvailable' | translate }}</p>
28+
</div>
29+
</div>
30+
</div>
31+
</div>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.metadata {
2+
border: 1px solid var(--grey-2);
3+
border-radius: 0.75rem;
4+
}
5+
6+
.full-image {
7+
min-height: 100vh;
8+
min-width: 100%;
9+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { Store } from '@ngxs/store';
2+
3+
import { MockProvider } from 'ng-mocks';
4+
5+
import { Mock } from 'vitest';
6+
7+
import { ComponentFixture, TestBed } from '@angular/core/testing';
8+
import { ActivatedRoute, Router } from '@angular/router';
9+
10+
import { FileKind } from '@osf/shared/enums/file-kind.enum';
11+
import { FileDetailsModel } from '@osf/shared/models/files/file.model';
12+
import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model';
13+
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
14+
15+
import { provideOSFCore } from '@testing/osf.testing.provider';
16+
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
17+
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
18+
import {
19+
BaseSetupOverrides,
20+
mergeSignalOverrides,
21+
provideMockStore,
22+
SignalOverride,
23+
} from '@testing/providers/store-provider.mock';
24+
import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock';
25+
26+
import { FilesSelectors, GetFile } from '../../store';
27+
28+
import { FilePreviewComponent } from './file-preview.component';
29+
30+
interface SetupOverrides extends BaseSetupOverrides {
31+
hasViewOnlyParam?: boolean;
32+
viewOnlyParam?: string | null;
33+
renderLink?: string;
34+
}
35+
36+
describe('FilePreviewComponent', () => {
37+
let component: FilePreviewComponent;
38+
let fixture: ComponentFixture<FilePreviewComponent>;
39+
let store: Store;
40+
let mockRouter: RouterMockType;
41+
let viewOnlyService: ViewOnlyLinkHelperMockType;
42+
43+
const encodedDownloadUrl = 'https://files.osf.io/v1/resources/abc/providers/osfstorage/file.txt';
44+
const defaultRenderLink = `https://mfr.osf.io/render?url=${encodeURIComponent(encodedDownloadUrl)}`;
45+
46+
function buildFileDetailsModel(renderLink: string): FileDetailsModel {
47+
return {
48+
id: 'file-1',
49+
guid: 'file-guid-1',
50+
name: 'file.txt',
51+
kind: FileKind.File,
52+
path: '/file.txt',
53+
size: 128,
54+
materializedPath: '/file.txt',
55+
dateModified: '2026-01-01T00:00:00.000Z',
56+
dateCreated: '2026-01-01T00:00:00.000Z',
57+
lastTouched: null,
58+
tags: [],
59+
currentVersion: 1,
60+
showAsUnviewed: false,
61+
extra: {
62+
hashes: {
63+
md5: 'md5',
64+
sha256: 'sha256',
65+
},
66+
downloads: 1,
67+
},
68+
links: {
69+
info: '',
70+
move: '',
71+
upload: '',
72+
delete: '',
73+
download: '',
74+
render: renderLink,
75+
html: '',
76+
self: '',
77+
},
78+
target: {} as unknown as BaseNodeModel,
79+
};
80+
}
81+
82+
const defaultSignals: SignalOverride[] = [
83+
{ selector: FilesSelectors.isOpenedFileLoading, value: false },
84+
{ selector: FilesSelectors.getOpenedFile, value: buildFileDetailsModel(defaultRenderLink) },
85+
];
86+
87+
function setup(overrides: SetupOverrides = {}) {
88+
const route = ActivatedRouteMockBuilder.create()
89+
.withParams(overrides.routeParams ?? { fileGuid: 'file-1' })
90+
.build();
91+
mockRouter = RouterMockBuilder.create().withUrl('/files/file-1/preview').build();
92+
viewOnlyService = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnlyParam ?? false);
93+
viewOnlyService.getViewOnlyParam = vi.fn().mockReturnValue(overrides.viewOnlyParam ?? null);
94+
95+
const signals = mergeSignalOverrides(defaultSignals, [
96+
{
97+
selector: FilesSelectors.getOpenedFile,
98+
value: buildFileDetailsModel(overrides.renderLink ?? defaultRenderLink),
99+
},
100+
...(overrides.selectorOverrides ?? []),
101+
]);
102+
103+
TestBed.configureTestingModule({
104+
imports: [FilePreviewComponent],
105+
providers: [
106+
provideOSFCore(),
107+
MockProvider(ActivatedRoute, route),
108+
MockProvider(Router, mockRouter),
109+
MockProvider(ViewOnlyLinkHelperService, viewOnlyService),
110+
provideMockStore({ signals }),
111+
],
112+
});
113+
114+
store = TestBed.inject(Store);
115+
fixture = TestBed.createComponent(FilePreviewComponent);
116+
component = fixture.componentInstance;
117+
fixture.detectChanges();
118+
}
119+
120+
it('should create', () => {
121+
setup();
122+
123+
expect(component).toBeTruthy();
124+
});
125+
126+
it('should dispatch get file action with route file guid on init', () => {
127+
setup();
128+
129+
expect(store.dispatch).toHaveBeenCalledWith(new GetFile('file-1'));
130+
});
131+
132+
it('should keep mfr url unchanged when render link has no nested url param', () => {
133+
setup({ renderLink: 'https://mfr.osf.io/render' });
134+
(store.dispatch as Mock).mockClear();
135+
136+
const result = component.getMfrUrlWithVersion('2');
137+
138+
expect(result).toBe('https://mfr.osf.io/render');
139+
expect(store.dispatch).not.toHaveBeenCalled();
140+
});
141+
142+
it('should append version param to nested download url', () => {
143+
setup();
144+
145+
const result = component.getMfrUrlWithVersion('3');
146+
147+
expect(result).toContain('https://mfr.osf.io/render?');
148+
expect(result).toContain(encodeURIComponent('version=3'));
149+
});
150+
151+
it('should append view only param when present', () => {
152+
setup({ hasViewOnlyParam: true, viewOnlyParam: 'view-token-1' });
153+
154+
const result = component.getMfrUrlWithVersion();
155+
156+
expect(result).toContain(encodeURIComponent('view_only=view-token-1'));
157+
});
158+
159+
it('should return null for empty render link', () => {
160+
setup({ renderLink: '' });
161+
162+
const result = component.getMfrUrlWithVersion();
163+
164+
expect(result).toBeNull();
165+
});
166+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { createDispatchMap, select } from '@ngxs/store';
2+
3+
import { TranslatePipe } from '@ngx-translate/core';
4+
5+
import { switchMap } from 'rxjs';
6+
7+
import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, inject } from '@angular/core';
8+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9+
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
10+
import { ActivatedRoute, Router } from '@angular/router';
11+
12+
import { FilesSelectors, GetFile } from '@osf/features/files/store';
13+
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
14+
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
15+
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
16+
17+
@Component({
18+
selector: 'osf-draft-file-detail',
19+
imports: [SubHeaderComponent, LoadingSpinnerComponent, TranslatePipe],
20+
templateUrl: './file-preview.component.html',
21+
styleUrl: './file-preview.component.scss',
22+
changeDetection: ChangeDetectionStrategy.OnPush,
23+
})
24+
export class FilePreviewComponent {
25+
@HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full';
26+
27+
private readonly router = inject(Router);
28+
private readonly route = inject(ActivatedRoute);
29+
private readonly sanitizer = inject(DomSanitizer);
30+
private readonly destroyRef = inject(DestroyRef);
31+
private readonly viewOnlyService = inject(ViewOnlyLinkHelperService);
32+
33+
private readonly actions = createDispatchMap({ getFile: GetFile });
34+
35+
file = select(FilesSelectors.getOpenedFile);
36+
isFileLoading = select(FilesSelectors.isOpenedFileLoading);
37+
38+
isIframeLoading = true;
39+
safeLink: SafeResourceUrl | null = null;
40+
41+
hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router));
42+
43+
constructor() {
44+
this.route.params
45+
.pipe(
46+
takeUntilDestroyed(this.destroyRef),
47+
switchMap((params) => this.actions.getFile(params['fileGuid']))
48+
)
49+
.subscribe(() => this.getIframeLink(''));
50+
}
51+
52+
getIframeLink(version: string) {
53+
const url = this.getMfrUrlWithVersion(version);
54+
if (url) {
55+
this.safeLink = this.sanitizer.bypassSecurityTrustResourceUrl(url);
56+
}
57+
}
58+
59+
getMfrUrlWithVersion(version?: string): string | null {
60+
const mfrUrl = this.file()?.links.render;
61+
if (!mfrUrl) return null;
62+
const mfrUrlObj = new URL(mfrUrl);
63+
const encodedDownloadUrl = mfrUrlObj.searchParams.get('url');
64+
if (!encodedDownloadUrl) return mfrUrl;
65+
66+
const downloadUrlObj = new URL(decodeURIComponent(encodedDownloadUrl));
67+
68+
if (version) downloadUrlObj.searchParams.set('version', version);
69+
70+
if (this.hasViewOnly()) {
71+
const viewOnlyParam = this.viewOnlyService.getViewOnlyParam();
72+
if (viewOnlyParam) downloadUrlObj.searchParams.set('view_only', viewOnlyParam);
73+
}
74+
75+
mfrUrlObj.searchParams.set('url', downloadUrlObj.toString());
76+
77+
return mfrUrlObj.toString();
78+
}
79+
}

src/app/features/registries/components/custom-step/custom-step.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ <h3 class="mb-2">{{ 'files.actions.uploadFile' | translate }}</h3>
179179
[projectId]="projectId()"
180180
[provider]="provider()"
181181
(attachFile)="onAttachFile($event, q.responseKey!)"
182+
(openFile)="onOpenFile($event)"
182183
[filesViewOnly]="filesViewOnly()"
183184
></osf-files-control>
184185
</div>

src/app/features/registries/components/custom-step/custom-step.component.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export class CustomStepComponent implements OnDestroy {
9898
readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES;
9999

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

103104
stepForm: FormGroup = this.fb.group({});
@@ -135,6 +136,13 @@ export class CustomStepComponent implements OnDestroy {
135136
});
136137
}
137138

139+
onOpenFile(file: FileModel): void {
140+
if (this.draftId() && file.guid) {
141+
const url = this.router.serializeUrl(this.router.createUrlTree([this.draftId(), 'files', file.guid, 'preview']));
142+
window.open(url, '_blank');
143+
}
144+
}
145+
138146
removeFromAttachedFiles(file: AttachedFile, questionKey: string): void {
139147
if (!this.attachedFiles[questionKey]) {
140148
return;

src/app/features/registries/components/files-control/files-control.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
[provider]="provider()"
5353
[selectedFiles]="filesSelection"
5454
(selectFile)="onFileTreeSelected($event)"
55-
(entryFileClicked)="selectFile($event)"
55+
(entryFileClicked)="onEntryFileClicked($event)"
5656
(uploadFilesConfirmed)="uploadFiles($event)"
5757
(loadFiles)="onLoadFiles($event)"
5858
(setCurrentFolder)="setCurrentFolder($event)"

src/app/features/registries/components/files-control/files-control.component.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class FilesControlComponent {
5454
provider = input.required<string>();
5555
filesViewOnly = input<boolean>(false);
5656
attachFile = output<FileModel>();
57+
openFile = output<FileModel>();
5758

5859
private readonly filesService = inject(FilesService);
5960
private readonly customDialogService = inject(CustomDialogService);
@@ -153,6 +154,11 @@ export class FilesControlComponent {
153154
});
154155
}
155156

157+
onEntryFileClicked(file: FileModel): void {
158+
this.selectFile(file);
159+
this.openFile.emit(file);
160+
}
161+
156162
selectFile(file: FileModel): void {
157163
if (this.filesViewOnly()) return;
158164
this.attachFile.emit(file);

src/assets/i18n/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,8 @@
615615
"resourceLanguage": "Resource Language",
616616
"resourceType": "Resource Type"
617617
},
618-
"title": "File Metadata"
618+
"title": "File Metadata",
619+
"previewNotAvailable": "File or Registration metadata not available in preview mode."
619620
},
620621
"keywords": {
621622
"title": "Keywords"

0 commit comments

Comments
 (0)