From 21ae8fc114516d7b67c1b8d851500ac7ea5a1106 Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Thu, 25 Sep 2025 10:39:55 +0300 Subject: [PATCH 1/2] fix(files): add redirect to file detail page --- src/app/app.routes.ts | 15 +++++++++ .../file-redirect.component.html | 0 .../file-redirect.component.scss | 4 +++ .../file-redirect.component.spec.ts | 22 +++++++++++++ .../file-redirect/file-redirect.component.ts | 31 +++++++++++++++++++ .../file-link/file-link.component.ts | 2 +- 6 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/app/features/files/pages/file-redirect/file-redirect.component.html create mode 100644 src/app/features/files/pages/file-redirect/file-redirect.component.scss create mode 100644 src/app/features/files/pages/file-redirect/file-redirect.component.spec.ts create mode 100644 src/app/features/files/pages/file-redirect/file-redirect.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 9ca9fbd2a..2e933f203 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -157,6 +157,21 @@ export const routes: Routes = [ import('./core/components/request-access/request-access.component').then((mod) => mod.RequestAccessComponent), data: { skipBreadcrumbs: true }, }, + { + path: ':id/files/:provider/:fileId', + loadComponent: () => + import('./features/files/pages/file-redirect/file-redirect.component').then((m) => m.FileRedirectComponent), + }, + { + path: 'project/:id/files/:provider/:fileId', + loadComponent: () => + import('./features/files/pages/file-redirect/file-redirect.component').then((m) => m.FileRedirectComponent), + }, + { + path: 'project/:id/node/:nodeId/files/:provider/:fileId', + loadComponent: () => + import('./features/files/pages/file-redirect/file-redirect.component').then((m) => m.FileRedirectComponent), + }, { path: ':id', canMatch: [isFileGuard], diff --git a/src/app/features/files/pages/file-redirect/file-redirect.component.html b/src/app/features/files/pages/file-redirect/file-redirect.component.html new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/files/pages/file-redirect/file-redirect.component.scss b/src/app/features/files/pages/file-redirect/file-redirect.component.scss new file mode 100644 index 000000000..2453e2916 --- /dev/null +++ b/src/app/features/files/pages/file-redirect/file-redirect.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + flex: 1; +} diff --git a/src/app/features/files/pages/file-redirect/file-redirect.component.spec.ts b/src/app/features/files/pages/file-redirect/file-redirect.component.spec.ts new file mode 100644 index 000000000..a1fcb20e4 --- /dev/null +++ b/src/app/features/files/pages/file-redirect/file-redirect.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileRedirectComponent } from './file-redirect.component'; + +describe.skip('FileRedirectComponent', () => { + let component: FileRedirectComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FileRedirectComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(FileRedirectComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/files/pages/file-redirect/file-redirect.component.ts b/src/app/features/files/pages/file-redirect/file-redirect.component.ts new file mode 100644 index 000000000..dfe42b96b --- /dev/null +++ b/src/app/features/files/pages/file-redirect/file-redirect.component.ts @@ -0,0 +1,31 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { FilesService } from '@osf/shared/services'; + +@Component({ + selector: 'osf-file-redirect', + imports: [], + templateUrl: './file-redirect.component.html', + styleUrl: './file-redirect.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FileRedirectComponent { + readonly route = inject(ActivatedRoute); + readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly filesService = inject(FilesService); + + readonly fileId = this.route.snapshot.paramMap.get('fileId') ?? ''; + constructor() { + if (this.fileId) { + this.filesService + .getFileGuid(this.fileId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((file) => { + this.router.navigate([file.guid]); + }); + } + } +} diff --git a/src/app/shared/components/file-link/file-link.component.ts b/src/app/shared/components/file-link/file-link.component.ts index d494d838a..480d41601 100644 --- a/src/app/shared/components/file-link/file-link.component.ts +++ b/src/app/shared/components/file-link/file-link.component.ts @@ -39,7 +39,7 @@ export class FileLinkComponent { .getFileGuid(fileId) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((file) => { - this.router.navigate(['/files', file.guid]); + this.router.navigate([file.guid]); }); } } From be8ade32d1a9225e31f0cfb859e81aa721d1bd3f Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Fri, 26 Sep 2025 17:30:27 +0300 Subject: [PATCH 2/2] fix(files): add supported features --- .../constants/file-provider.constants.ts | 4 ++ .../files/pages/files/files.component.html | 4 +- .../files/pages/files/files.component.ts | 40 ++++++++++++++++++- src/app/features/files/store/files.actions.ts | 9 +++++ src/app/features/files/store/files.model.ts | 11 +++++ .../features/files/store/files.selectors.ts | 6 +++ src/app/features/files/store/files.state.ts | 25 +++++++++++- .../file-menu/file-menu.component.ts | 23 ++++++++--- .../files-tree/files-tree.component.html | 9 +++-- .../files-tree/files-tree.component.ts | 4 +- .../enums/addon-supported-features.enum.ts | 11 +++++ src/app/shared/enums/index.ts | 1 + .../models/files/file-menu-action.model.ts | 2 + src/app/shared/services/files.service.ts | 10 +++++ 14 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 src/app/shared/enums/addon-supported-features.enum.ts diff --git a/src/app/features/files/constants/file-provider.constants.ts b/src/app/features/files/constants/file-provider.constants.ts index f494f731a..18f9514f1 100644 --- a/src/app/features/files/constants/file-provider.constants.ts +++ b/src/app/features/files/constants/file-provider.constants.ts @@ -7,4 +7,8 @@ export const FileProvider = { WebDav: 'webdav', S3: 's3', GitHub: 'github', + Bitbucket: 'bitbucket', + GitLab: 'gitlab', + Figshare: 'figshare', + Dataverse: 'dataverse', }; diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 00615be84..5500aa4c6 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -56,7 +56,7 @@ - @if (canEdit() && !hasViewOnly()) { + @if (canUploadFiles() && !hasViewOnly()) { { + const provider = this.provider(); + const supportedFeatures = this.supportedFeatures()[provider] || []; + return this.mapMenuActions(supportedFeatures); + }); + readonly rootFoldersOptions = computed(() => { const rootFolders = this.rootFolders(); const addons = this.configuredStorageAddons(); @@ -205,7 +214,13 @@ export class FilesComponent { return !details.isRegistration && hasAdminOrWrite; }); - readonly isViewOnlyDownloadable = computed(() => this.resourceType() === ResourceType.Registration); + readonly isViewOnlyDownloadable = computed( + () => this.allowedMenuActions()[FileMenuType.Download] && this.resourceType() === ResourceType.Registration + ); + + canUploadFiles = computed( + () => this.supportedFeatures()[this.provider()]?.includes(SupportedFeature.AddUpdateFiles) && this.canEdit() + ); isButtonDisabled = computed(() => this.fileIsUploading() || this.isFilesLoading()); @@ -255,12 +270,16 @@ export class FilesComponent { const currentRootFolder = this.currentRootFolder(); if (currentRootFolder) { const provider = currentRootFolder.folder?.provider; + const storageId = currentRootFolder.folder?.id; // [NM TODO] Check if other providers allow revisions this.allowRevisions = provider === FileProvider.OsfStorage; this.isGoogleDrive.set(provider === FileProvider.GoogleDrive); if (this.isGoogleDrive()) { this.setGoogleAccountId(); } + if (storageId) { + this.actions.getStorageSupportedFeatures(storageId, provider); + } this.actions.setCurrentProvider(provider ?? FileProvider.OsfStorage); this.actions.setCurrentFolder(currentRootFolder.folder); } @@ -510,6 +529,23 @@ export class FilesComponent { } } + private mapMenuActions(supportedFeatures: SupportedFeature[]): Record { + return { + [FileMenuType.Download]: supportedFeatures.includes(SupportedFeature.DownloadAsZip), + [FileMenuType.Rename]: supportedFeatures.includes(SupportedFeature.AddUpdateFiles), + [FileMenuType.Delete]: supportedFeatures.includes(SupportedFeature.DeleteFiles), + [FileMenuType.Move]: + supportedFeatures.includes(SupportedFeature.CopyInto) && + supportedFeatures.includes(SupportedFeature.DeleteFiles) && + supportedFeatures.includes(SupportedFeature.AddUpdateFiles), + [FileMenuType.Embed]: true, + [FileMenuType.Share]: true, + [FileMenuType.Copy]: + supportedFeatures.includes(SupportedFeature.CopyInto) && + supportedFeatures.includes(SupportedFeature.AddUpdateFiles), + }; + } + openGoogleFilePicker(): void { this.googleFilePickerComponent()?.createPicker(); this.updateFilesList(); diff --git a/src/app/features/files/store/files.actions.ts b/src/app/features/files/store/files.actions.ts index a6fc56f71..41de147de 100644 --- a/src/app/features/files/store/files.actions.ts +++ b/src/app/features/files/store/files.actions.ts @@ -156,6 +156,15 @@ export class GetConfiguredStorageAddons { constructor(public resourceUri: string) {} } +export class GetStorageSupportedFeatures { + static readonly type = '[Files] Get Storage Supported Features'; + + constructor( + public storageId: string, + public providerName: string + ) {} +} + export class ResetState { static readonly type = '[Files] Reset State'; } diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index c00cdbe00..5f6109aa9 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -1,3 +1,4 @@ +import { SupportedFeature } from '@osf/shared/enums'; import { ContributorModel, OsfFile, ResourceMetadata } from '@shared/models'; import { ConfiguredAddonModel } from '@shared/models/addons'; import { AsyncStateModel, AsyncStateWithTotalCount } from '@shared/models/store'; @@ -22,6 +23,7 @@ export interface FilesStateModel { rootFolders: AsyncStateModel; configuredStorageAddons: AsyncStateModel; isAnonymous: boolean; + storageSupportedFeatures: Record; } export const FILES_STATE_DEFAULTS: FilesStateModel = { @@ -83,4 +85,13 @@ export const FILES_STATE_DEFAULTS: FilesStateModel = { error: null, }, isAnonymous: false, + storageSupportedFeatures: { + [FileProvider.OsfStorage]: [ + SupportedFeature.AddUpdateFiles, + SupportedFeature.DeleteFiles, + SupportedFeature.DownloadAsZip, + SupportedFeature.FileVersions, + SupportedFeature.CopyInto, + ], + }, }; diff --git a/src/app/features/files/store/files.selectors.ts b/src/app/features/files/store/files.selectors.ts index 1c0ce291c..fa0e13d88 100644 --- a/src/app/features/files/store/files.selectors.ts +++ b/src/app/features/files/store/files.selectors.ts @@ -1,5 +1,6 @@ import { Selector } from '@ngxs/store'; +import { SupportedFeature } from '@osf/shared/enums'; import { ConfiguredAddonModel, ContributorModel, OsfFile, ResourceMetadata } from '@shared/models'; import { OsfFileCustomMetadata, OsfFileRevision } from '../models'; @@ -137,4 +138,9 @@ export class FilesSelectors { static isConfiguredStorageAddonsLoading(state: FilesStateModel): boolean { return state.configuredStorageAddons.isLoading; } + + @Selector([FilesState]) + static getStorageSupportedFeatures(state: FilesStateModel): Record { + return state.storageSupportedFeatures || {}; + } } diff --git a/src/app/features/files/store/files.state.ts b/src/app/features/files/store/files.state.ts index 4aac568dd..af09ffc17 100644 --- a/src/app/features/files/store/files.state.ts +++ b/src/app/features/files/store/files.state.ts @@ -1,9 +1,10 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, finalize, forkJoin, tap } from 'rxjs'; +import { catchError, EMPTY, finalize, forkJoin, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { SupportedFeature } from '@osf/shared/enums'; import { handleSectionError } from '@osf/shared/helpers'; import { FilesService, ToastService } from '@osf/shared/services'; @@ -22,6 +23,7 @@ import { GetMoveFileFiles, GetRootFolderFiles, GetRootFolders, + GetStorageSupportedFeatures, RenameEntry, ResetState, SetCurrentFolder, @@ -303,6 +305,27 @@ export class FilesState { ); } + @Action(GetStorageSupportedFeatures) + getStorageSupportedFeatures(ctx: StateContext, action: GetStorageSupportedFeatures) { + const state = ctx.getState(); + if (state.storageSupportedFeatures[action.providerName]) { + return EMPTY; + } + return this.filesService.getExternalStorageService(action.storageId).pipe( + tap((addon) => { + const providerName = addon.externalServiceName; + const currentFeatures = state.storageSupportedFeatures; + ctx.patchState({ + storageSupportedFeatures: { + ...currentFeatures, + [providerName]: (addon.supportedFeatures ?? []) as SupportedFeature[], + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'storageSupportedFeatures', error)) + ); + } + @Action(ResetState) resetState(ctx: StateContext) { ctx.patchState(FILES_STATE_DEFAULTS); diff --git a/src/app/shared/components/file-menu/file-menu.component.ts b/src/app/shared/components/file-menu/file-menu.component.ts index f2a39ab33..e452ed774 100644 --- a/src/app/shared/components/file-menu/file-menu.component.ts +++ b/src/app/shared/components/file-menu/file-menu.component.ts @@ -8,7 +8,7 @@ import { Component, computed, inject, input, output, viewChild } from '@angular/ import { Router } from '@angular/router'; import { FileMenuType } from '@osf/shared/enums'; -import { FileMenuAction, FileMenuData } from '@osf/shared/models'; +import { FileMenuAction, FileMenuData, FileMenuFlags } from '@osf/shared/models'; import { MenuManagerService } from '@osf/shared/services'; import { hasViewOnlyParam } from '@shared/helpers'; @@ -21,9 +21,10 @@ import { hasViewOnlyParam } from '@shared/helpers'; export class FileMenuComponent { private router = inject(Router); private menuManager = inject(MenuManagerService); + isFolder = input(false); + allowedActions = input({} as FileMenuFlags); menu = viewChild.required('menu'); action = output(); - isFolder = input(false); hasViewOnly = computed(() => { return hasViewOnlyParam(this.router); @@ -108,8 +109,16 @@ export class FileMenuComponent { menuItems = computed(() => { if (this.hasViewOnly()) { - const allowedActionsForFiles = [FileMenuType.Download, FileMenuType.Embed, FileMenuType.Share, FileMenuType.Copy]; - const allowedActionsForFolders = [FileMenuType.Download, FileMenuType.Copy]; + const allowedActionsForFiles = [ + FileMenuType.Download, + FileMenuType.Embed, + FileMenuType.Share, + FileMenuType.Copy, + ].filter((action) => this.allowedActions()[action]); + + const allowedActionsForFolders = [FileMenuType.Download, FileMenuType.Copy].filter( + (action) => this.allowedActions()[action] + ); const allowedActions = this.isFolder() ? allowedActionsForFolders : allowedActionsForFiles; @@ -128,9 +137,11 @@ export class FileMenuComponent { if (this.isFolder()) { const disallowedActions = [FileMenuType.Share, FileMenuType.Embed]; - return this.allMenuItems.filter((item) => !disallowedActions.includes(item.id as FileMenuType)); + return this.allMenuItems.filter( + (item) => !disallowedActions.includes(item.id as FileMenuType) && this.allowedActions()[item.id as FileMenuType] + ); } - return this.allMenuItems; + return this.allMenuItems.filter((item) => this.allowedActions()[item.id as FileMenuType]); }); onMenuToggle(event: Event): void { diff --git a/src/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index bd600d050..c9fc8118b 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -1,5 +1,5 @@
- @if (!hasViewOnly()) { + @if (!hasViewOnly() && supportUpload()) {
@@ -76,6 +76,7 @@ @@ -105,7 +106,7 @@ } @if (!files().length) {
- @if (hasViewOnly()) { + @if (hasViewOnly() || !supportUpload()) {

{{ 'files.emptyState' | translate }}

} @else {
diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index 248bf93a5..fa96ca187 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -33,7 +33,7 @@ import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; import { StopPropagationDirective } from '@osf/shared/directives'; import { FileMenuType } from '@osf/shared/enums'; import { hasViewOnlyParam } from '@osf/shared/helpers'; -import { FileLabelModel, FileMenuAction, FilesTreeActions, OsfFile } from '@osf/shared/models'; +import { FileLabelModel, FileMenuAction, FileMenuFlags, FilesTreeActions, OsfFile } from '@osf/shared/models'; import { FileSizePipe } from '@osf/shared/pipes'; import { CustomConfirmationService, FilesService, ToastService } from '@osf/shared/services'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; @@ -83,6 +83,8 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { viewOnly = input(true); viewOnlyDownloadable = input(false); provider = input(); + allowedMenuActions = input({} as FileMenuFlags); + supportUpload = input(true); isDragOver = signal(false); hasViewOnly = computed(() => hasViewOnlyParam(this.router) || this.viewOnly()); diff --git a/src/app/shared/enums/addon-supported-features.enum.ts b/src/app/shared/enums/addon-supported-features.enum.ts new file mode 100644 index 000000000..45b045f00 --- /dev/null +++ b/src/app/shared/enums/addon-supported-features.enum.ts @@ -0,0 +1,11 @@ +export enum SupportedFeature { + AddUpdateFiles = 'ADD_UPDATE_FILES', + CopyInto = 'COPY_INTO', + DeleteFiles = 'DELETE_FILES', + DownloadAsZip = 'DOWNLOAD_AS_ZIP', + FileVersions = 'FILE_VERSIONS', + Forking = 'FORKING', + Logs = 'LOGS', + Permissions = 'PERMISSIONS', + Registering = 'REGISTERING', +} diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index fb580a69f..301cfef44 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -1,5 +1,6 @@ export * from './addon-form-controls.enum'; export * from './addon-service-names.enum'; +export * from './addon-supported-features.enum'; export * from './addon-tab.enum'; export * from './addon-type.enum'; export * from './addons-category.enum'; diff --git a/src/app/shared/models/files/file-menu-action.model.ts b/src/app/shared/models/files/file-menu-action.model.ts index d5566932d..f151e2f0e 100644 --- a/src/app/shared/models/files/file-menu-action.model.ts +++ b/src/app/shared/models/files/file-menu-action.model.ts @@ -8,3 +8,5 @@ export interface FileMenuAction { export interface FileMenuData { type: string; } + +export type FileMenuFlags = Record; diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 0bb5f4764..8970afbe4 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -21,6 +21,8 @@ import { } from '@osf/features/files/models'; import { AddFileResponse, + AddonGetResponseJsonApi, + AddonModel, ApiData, ConfiguredAddonGetResponseJsonApi, ConfiguredAddonModel, @@ -318,4 +320,12 @@ export class FilesService { }) ); } + + getExternalStorageService(serviceId: string): Observable { + return this.jsonApiService + .get< + JsonApiResponse + >(`${this.addonsApiUrl}/configured-storage-addons/${serviceId}/external_storage_service/`) + .pipe(map((response) => AddonMapper.fromResponse(response.data))); + } }