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(); @@ -206,7 +215,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()); @@ -256,12 +271,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); } @@ -511,6 +530,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 44e3a6857..48086e274 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 @@ @@ -106,7 +107,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 7eea3e257..cdae7ea25 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -36,7 +36,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'; @@ -88,6 +88,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))); + } }