diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts index 6e91d5349..0a6fa8dd5 100644 --- a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts +++ b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts @@ -1,12 +1,9 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig } from 'primeng/dynamicdialog'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { FilesService } from '@osf/shared/services/files.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -14,11 +11,8 @@ import { ToastService } from '@osf/shared/services/toast.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock'; import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; -import { FilesSelectors } from '../../store'; - import { ConfirmMoveFileDialogComponent } from './confirm-move-file-dialog.component'; describe('ConfirmConfirmMoveFileDialogComponent', () => { @@ -31,10 +25,7 @@ describe('ConfirmConfirmMoveFileDialogComponent', () => { }; TestBed.configureTestingModule({ - imports: [ - ConfirmMoveFileDialogComponent, - ...MockComponents(IconComponent, LoadingSpinnerComponent, FileSelectDestinationComponent), - ], + imports: [ConfirmMoveFileDialogComponent], providers: [ provideOSFCore(), provideDynamicDialogRefMock(), @@ -42,12 +33,6 @@ describe('ConfirmConfirmMoveFileDialogComponent', () => { MockProvider(FilesService), MockProvider(ToastService, ToastServiceMock.simple()), MockProvider(CustomConfirmationService, CustomConfirmationServiceMock.simple()), - provideMockStore({ - signals: [ - { selector: FilesSelectors.getMoveDialogFiles, value: [] }, - { selector: FilesSelectors.getProvider, value: null }, - ], - }), ], }); @@ -63,10 +48,5 @@ describe('ConfirmConfirmMoveFileDialogComponent', () => { it('should initialize with correct properties', () => { expect(component.config).toBeDefined(); expect(component.dialogRef).toBeDefined(); - expect(component.files).toBeDefined(); - }); - - it('should get files from store', () => { - expect(component.files()).toEqual([]); }); }); diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts index 038478525..2bb6771f8 100644 --- a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts +++ b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts @@ -1,5 +1,3 @@ -import { select } from '@ngxs/store'; - import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; @@ -11,7 +9,6 @@ import { catchError } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FilesSelectors } from '@osf/features/files/store'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { FilesService } from '@osf/shared/services/files.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -35,8 +32,6 @@ export class ConfirmMoveFileDialogComponent { private readonly toastService = inject(ToastService); private readonly customConfirmationService = inject(CustomConfirmationService); - readonly files = select(FilesSelectors.getMoveDialogFiles); - readonly provider = this.config.data.storageProvider; private fileProjectId = this.config.data.resourceId; @@ -112,9 +107,7 @@ export class ConfirmMoveFileDialogComponent { this.customConfirmationService.confirmDelete({ headerKey: conflictFiles.length > 1 ? 'files.dialogs.replaceFile.multiple' : 'files.dialogs.replaceFile.single', messageKey: 'files.dialogs.replaceFile.message', - messageParams: { - name: conflictFiles.map((c) => c.file.name).join(', '), - }, + messageParams: { name: conflictFiles.map((c) => c.file.name).join(', ') }, acceptLabelKey: 'common.buttons.replace', onConfirm: () => { const replaceRequests$ = conflictFiles.map(({ link }) => diff --git a/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.ts b/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.ts index a47be2ff6..35e9bb2ef 100644 --- a/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.ts +++ b/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.ts @@ -11,7 +11,8 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { languageCodes } from '@osf/shared/constants/language.const'; import { resourceTypes } from '@osf/shared/constants/resource-types.const'; -import { OsfFileCustomMetadata, PatchFileMetadata } from '../../models'; +import { OsfFileCustomMetadata } from '../../models/file-custom-metadata.model'; +import { PatchFileMetadata } from '../../models/patch-file-metadata.model'; @Component({ selector: 'osf-edit-file-metadata-dialog', diff --git a/src/app/features/files/components/file-browser-info/file-browser-info.component.ts b/src/app/features/files/components/file-browser-info/file-browser-info.component.ts index 7110c2873..ea5e520d6 100644 --- a/src/app/features/files/components/file-browser-info/file-browser-info.component.ts +++ b/src/app/features/files/components/file-browser-info/file-browser-info.component.ts @@ -24,7 +24,7 @@ export class FileBrowserInfoComponent { readonly infoItems = FILE_BROWSER_INFO_ITEMS; - readonly filteredInfoItems = computed(() => { - return this.infoItems.filter((item) => item.showForResourceTypes.includes(this.resourceType())); - }); + readonly filteredInfoItems = computed(() => + this.infoItems.filter((item) => item.showForResourceTypes.includes(this.resourceType())) + ); } diff --git a/src/app/features/files/components/file-keywords/file-keywords.component.spec.ts b/src/app/features/files/components/file-keywords/file-keywords.component.spec.ts index 4daf81e0e..fa9da88d8 100644 --- a/src/app/features/files/components/file-keywords/file-keywords.component.spec.ts +++ b/src/app/features/files/components/file-keywords/file-keywords.component.spec.ts @@ -1,4 +1,3 @@ -import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideOSFCore } from '@testing/osf.testing.provider'; @@ -26,10 +25,10 @@ describe('FileKeywordsComponent', () => { provideOSFCore(), provideMockStore({ signals: [ - { selector: FilesSelectors.getFileTags, value: signal(mockTags) }, - { selector: FilesSelectors.isFileTagsLoading, value: signal(false) }, - { selector: FilesSelectors.getOpenedFile, value: signal(mockFile) }, - { selector: FilesSelectors.hasWriteAccess, value: signal(true) }, + { selector: FilesSelectors.getFileTags, value: mockTags }, + { selector: FilesSelectors.isFileTagsLoading, value: false }, + { selector: FilesSelectors.getOpenedFile, value: mockFile }, + { selector: FilesSelectors.hasWriteAccess, value: true }, ], }), ], diff --git a/src/app/features/files/components/file-metadata/file-metadata.component.ts b/src/app/features/files/components/file-metadata/file-metadata.component.ts index 2b9f22052..01e88fabb 100644 --- a/src/app/features/files/components/file-metadata/file-metadata.component.ts +++ b/src/app/features/files/components/file-metadata/file-metadata.component.ts @@ -18,7 +18,7 @@ import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-h import { LanguageCodeModel } from '@shared/models/language-code.model'; import { FileMetadataFields } from '../../constants'; -import { PatchFileMetadata } from '../../models'; +import { PatchFileMetadata } from '../../models/patch-file-metadata.model'; import { FilesSelectors, SetFileMetadata } from '../../store'; import { EditFileMetadataDialogComponent } from '../edit-file-metadata-dialog/edit-file-metadata-dialog.component'; diff --git a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html index 640ad07d4..dd0eeed49 100644 --- a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html +++ b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html @@ -9,7 +9,7 @@

} @else { - @for (funder of resourceMetadata()?.funders; track $index) { + @for (funder of resourceMetadata()?.funders; track funder.funderIdentifier) {
@if (funder.funderName) {
@@ -23,10 +23,10 @@

{{ 'files.detail.resourceMetadata.fields.awardTitle' | translate }}

{{ funder.awardTitle }}
} - @if (funder.awardTitle) { + @if (funder.awardNumber) {

{{ 'files.detail.resourceMetadata.fields.awardNumber' | translate }}

- {{ funder.awardTitle }} + {{ funder.awardNumber }}
} @if (funder.awardUri) { @@ -98,7 +98,7 @@

{{ 'common.labels.dateModified' | translate }}

@if (isResourceContributorsLoading()) { } @else { - @if (hasViewOnly() || contributors().length) { + @if (contributors().length) {

{{ 'common.labels.contributors' | translate }}

diff --git a/src/app/features/files/components/file-revisions/file-revisions.component.ts b/src/app/features/files/components/file-revisions/file-revisions.component.ts index a360dd71e..fcb0a8d9b 100644 --- a/src/app/features/files/components/file-revisions/file-revisions.component.ts +++ b/src/app/features/files/components/file-revisions/file-revisions.component.ts @@ -11,7 +11,7 @@ import { CopyButtonComponent } from '@osf/shared/components/copy-button/copy-but import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; -import { OsfFileRevision } from '../../models'; +import { OsfFileRevision } from '../../models/file-revisions.model'; @Component({ selector: 'osf-file-revisions', diff --git a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html index a5c2a5ffc..c29b33428 100644 --- a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html +++ b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html @@ -1,8 +1,8 @@ -@if (selectedFiles().length > 0) { +@if (selectedFilesCount() > 0) {
- {{ selectedFiles().length }} {{ 'files.selectedFiles' | translate }} + {{ selectedFilesCount() }} {{ 'files.selectedFiles' | translate }} ([]); + selectedFilesCount = input(0); canUpdateFiles = input(true); hasViewOnly = input(false); copySelected = output(); diff --git a/src/app/features/files/constants/file-browser-info.constants.ts b/src/app/features/files/constants/file-browser-info.constants.ts index 9e0c95ec0..1de435ecb 100644 --- a/src/app/features/files/constants/file-browser-info.constants.ts +++ b/src/app/features/files/constants/file-browser-info.constants.ts @@ -1,6 +1,6 @@ import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { FileInfoItem } from '../models'; +import { FileInfoItem } from '../models/info-item.model'; export const FILE_BROWSER_INFO_ITEMS: FileInfoItem[] = [ { diff --git a/src/app/features/files/constants/file-metadata-fields.constants.ts b/src/app/features/files/constants/file-metadata-fields.constants.ts index d67123b6a..e2c4e5a6f 100644 --- a/src/app/features/files/constants/file-metadata-fields.constants.ts +++ b/src/app/features/files/constants/file-metadata-fields.constants.ts @@ -1,4 +1,4 @@ -import { MetadataField } from '../models'; +import { MetadataField } from '../models/files-metadata-fields.model'; export const FileMetadataFields: MetadataField[] = [ { key: 'title', label: 'common.labels.title' }, diff --git a/src/app/features/files/constants/index.ts b/src/app/features/files/constants/index.ts index af9827638..bed8ae58c 100644 --- a/src/app/features/files/constants/index.ts +++ b/src/app/features/files/constants/index.ts @@ -1,4 +1,3 @@ -export * from './embed-content.constants'; export * from './file-browser-info.constants'; export * from './file-metadata-fields.constants'; export * from './file-provider.constants'; diff --git a/src/app/features/files/files.routes.ts b/src/app/features/files/files.routes.ts index 8b4cb2b77..9f53d2c08 100644 --- a/src/app/features/files/files.routes.ts +++ b/src/app/features/files/files.routes.ts @@ -29,11 +29,8 @@ export const filesRoutes: Routes = [ { path: ':fileGuid', data: { canonicalPathTemplate: 'files/:fileGuid' }, - loadComponent: () => { - return import('@osf/features/files/pages/file-detail/file-detail.component').then( - (c) => c.FileDetailComponent - ); - }, + loadComponent: () => + import('@osf/features/files/pages/file-detail/file-detail.component').then((c) => c.FileDetailComponent), }, ], }, diff --git a/src/app/features/files/mappers/file-custom-metadata.mapper.ts b/src/app/features/files/mappers/file-custom-metadata.mapper.ts index 3af893193..1292bd004 100644 --- a/src/app/features/files/mappers/file-custom-metadata.mapper.ts +++ b/src/app/features/files/mappers/file-custom-metadata.mapper.ts @@ -1,7 +1,8 @@ import { ApiData } from '@osf/shared/models/common/json-api.model'; import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; -import { FileCustomMetadata, OsfFileCustomMetadata } from '../models'; +import { OsfFileCustomMetadata } from '../models/file-custom-metadata.model'; +import { FileCustomMetadata } from '../models/get-file-metadata-response.model'; export function MapFileCustomMetadata(data: ApiData): OsfFileCustomMetadata { return { diff --git a/src/app/features/files/mappers/file-menu-actions.mapper.ts b/src/app/features/files/mappers/file-menu-actions.mapper.ts new file mode 100644 index 000000000..186a2b39d --- /dev/null +++ b/src/app/features/files/mappers/file-menu-actions.mapper.ts @@ -0,0 +1,17 @@ +import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; +import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; + +export function 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]: true, + }; +} diff --git a/src/app/features/files/mappers/file-revision.mapper.ts b/src/app/features/files/mappers/file-revision.mapper.ts index 19046d20f..0adb0231f 100644 --- a/src/app/features/files/mappers/file-revision.mapper.ts +++ b/src/app/features/files/mappers/file-revision.mapper.ts @@ -1,6 +1,7 @@ import { ApiData } from '@osf/shared/models/common/json-api.model'; -import { FileRevisionJsonApi, OsfFileRevision } from '../models'; +import { OsfFileRevision } from '../models/file-revisions.model'; +import { FileRevisionJsonApi } from '../models/get-file-revisions-response.model'; export function MapFileRevision(data: ApiData[]): OsfFileRevision[] { const revision = data.map((revision) => ({ diff --git a/src/app/features/files/models/files-actions-options.model.ts b/src/app/features/files/models/files-actions-options.model.ts new file mode 100644 index 000000000..cfe735208 --- /dev/null +++ b/src/app/features/files/models/files-actions-options.model.ts @@ -0,0 +1,24 @@ +import { Observable } from 'rxjs'; + +import { FileModel } from '@shared/models/files/file.model'; +import { FileFolderModel } from '@shared/models/files/file-folder.model'; + +export interface DeleteSelectedOptions { + files: FileModel[]; + deleteEntry: (link: string) => Observable; + onSuccess: () => void; +} + +export interface MoveFilesOptions { + files: FileModel[]; + action: 'move' | 'copy'; + resourceId: string; + storageProvider: string; + foldersStack: FileFolderModel[]; + initialFolder: FileFolderModel | null | undefined; +} + +export interface CreateFolderOptions { + newFolderLink: string; + createFolder: (newFolderLink: string, folderName: string) => Observable; +} diff --git a/src/app/features/files/models/files-upload-options.model.ts b/src/app/features/files/models/files-upload-options.model.ts new file mode 100644 index 000000000..94b3eccd8 --- /dev/null +++ b/src/app/features/files/models/files-upload-options.model.ts @@ -0,0 +1,16 @@ +import { FileLinkModel } from '@osf/shared/models/files/file-link.model'; + +export interface UploadFilesOptions { + files: File | File[]; + uploadLink: string; + allowRevisions: boolean; + onStart: (fileName: string) => void; + onProgress: (progress: number) => void; + onComplete: () => void; +} + +export interface UploadState { + completedUploads: number; + totalFiles: number; + conflictFiles: FileLinkModel[]; +} diff --git a/src/app/features/files/models/index.ts b/src/app/features/files/models/index.ts deleted file mode 100644 index 29d50dfc6..000000000 --- a/src/app/features/files/models/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './file-custom-metadata.model'; -export * from './file-revisions.model'; -export * from './files-metadata-fields.model'; -export * from './get-custom-metadata-response.model'; -export * from './get-file-metadata-response.model'; -export * from './get-file-revisions-response.model'; -export * from './get-short-info-response.model'; -export * from './info-item.model'; -export * from './patch-file-metadata.model'; diff --git a/src/app/features/files/pages/file-detail/file-detail.component.html b/src/app/features/files/pages/file-detail/file-detail.component.html index fc44a06e3..1e72e92f0 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.html +++ b/src/app/features/files/pages/file-detail/file-detail.component.html @@ -1,9 +1,6 @@ - +@let currentFile = file(); + + @@ -13,7 +10,7 @@ -
+
@@ -23,7 +20,7 @@
- @if (!isAnonymous() && !hasViewOnly() && hasWriteAccess()) { + @if (canManageFileActions()) { } - @if (file()?.links?.download) { + @if (currentFile?.links?.download) { } - @if (file()?.links?.render) { + @if (currentFile?.links?.render) {
@@ -57,7 +54,7 @@
} - @if (file()?.links?.html) { + @if (currentFile?.links?.html) {
} - @if (showDeleteButton()) { + @if (canManageFileActions()) { }
@@ -121,7 +118,7 @@ } @else { @if (filesSelection.length) { @if (!isMoveDialogOpened()) { diff --git a/src/app/features/files/pages/files/files.component.scss b/src/app/features/files/pages/files/files.component.scss index c9be697b1..fd15c2680 100644 --- a/src/app/features/files/pages/files/files.component.scss +++ b/src/app/features/files/pages/files/files.component.scss @@ -5,19 +5,3 @@ flex: 1; overflow: hidden; } - -.blue-text { - color: var(--pr-blue-1); -} - -.filename { - overflow-wrap: anywhere; -} - -.upload-dialog { - width: mix.rem(128px); -} - -.provider-name { - text-transform: capitalize; -} diff --git a/src/app/features/files/pages/files/files.component.spec.ts b/src/app/features/files/pages/files/files.component.spec.ts index 05c5727ac..c0afc4830 100644 --- a/src/app/features/files/pages/files/files.component.spec.ts +++ b/src/app/features/files/pages/files/files.component.spec.ts @@ -25,6 +25,7 @@ import { FileKind } from '@osf/shared/enums/file-kind.enum'; import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { getConfiguredStorageAddonDisplayName } from '@osf/shared/helpers/storage-addon-options.helper'; import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { CurrentResource } from '@osf/shared/models/current-resource.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; @@ -300,10 +301,10 @@ describe('FilesComponent', () => { expect(options[0].folder.id).toBe('folder-1'); }); - it('should return addon display name for non-osf provider in getAddonName', () => { + it('should resolve non-OSF provider display name via storage addon helper', () => { setup(); - const name = component.getAddonName(configuredAddons, FileProvider.GoogleDrive); + const name = getConfiguredStorageAddonDisplayName(configuredAddons, FileProvider.GoogleDrive, 'OSF Storage'); expect(name).toBe('Google Drive'); }); diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 00ab2a2ef..5167b548f 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -2,26 +2,13 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; import { Select } from 'primeng/select'; import { TableModule } from 'primeng/table'; -import { - catchError, - debounceTime, - distinctUntilChanged, - filter, - finalize, - forkJoin, - map, - of, - switchMap, - take, -} from 'rxjs'; +import { debounceTime, distinctUntilChanged, finalize, map, of, tap } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; -import { HttpEventType, HttpResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, @@ -39,22 +26,6 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { - CreateFolder, - DeleteEntry, - GetConfiguredStorageAddons, - GetFiles, - GetRootFolders, - GetStorageSupportedFeatures, - RenameEntry, - ResetFilesState, - SetCurrentProvider, - SetFilesCurrentFolder, - SetMoveDialogCurrentFolder, - SetSearch, - SetSort, -} from '@osf/features/files/store'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { FormSelectComponent } from '@osf/shared/components/form-select/form-select.component'; @@ -67,29 +38,43 @@ import { FILE_SIZE_LIMIT } from '@osf/shared/constants/files-limits.const'; import { ALL_SORT_OPTIONS } from '@osf/shared/constants/sort-options.const'; import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { mapRootFoldersToStorageLabels } from '@osf/shared/helpers/storage-addon-options.helper'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; +import { RenamedFileLinkModel } from '@osf/shared/models/files/renamed-file-link.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { CurrentResourceSelectors, GetResourceDetails } from '@osf/shared/stores/current-resource'; -import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model'; import { StorageItem } from '@shared/models/addons/storage-item.model'; import { FileModel } from '@shared/models/files/file.model'; import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileLabelModel } from '@shared/models/files/file-label.model'; import { DataciteService } from '@shared/services/datacite/datacite.service'; -import { - CreateFolderDialogComponent, - FileBrowserInfoComponent, - FilesSelectionActionsComponent, - MoveFileDialogComponent, -} from '../../components'; +import { FileBrowserInfoComponent } from '../../components/file-browser-info/file-browser-info.component'; +import { FilesSelectionActionsComponent } from '../../components/files-selection-actions/files-selection-actions.component'; import { FileProvider } from '../../constants'; -import { FilesSelectors } from '../../store'; +import { mapMenuActions } from '../../mappers/file-menu-actions.mapper'; +import { FilesActionsService } from '../../services/files-actions.service'; +import { FilesUploadService } from '../../services/files-upload.service'; +import { + CreateFolder, + DeleteEntry, + FilesSelectors, + GetConfiguredStorageAddons, + GetFiles, + GetRootFolders, + GetStorageSupportedFeatures, + RenameEntry, + ResetFilesState, + SetCurrentProvider, + SetFilesCurrentFolder, + SetMoveDialogCurrentFolder, + SetSearch, + SetSort, +} from '../../store'; @Component({ selector: 'osf-files', @@ -107,14 +92,13 @@ import { FilesSelectors } from '../../store'; SubHeaderComponent, FileUploadDialogComponent, ViewOnlyLinkMessageComponent, - GoogleFilePickerComponent, FilesSelectionActionsComponent, TranslatePipe, ], templateUrl: './files.component.html', styleUrl: './files.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TreeDragDropService], + providers: [FilesActionsService, FilesUploadService], }) export class FilesComponent { googleFilePickerComponent = viewChild(GoogleFilePickerComponent); @@ -128,15 +112,11 @@ export class FilesComponent { private readonly translateService = inject(TranslateService); private readonly router = inject(Router); private readonly dataciteService = inject(DataciteService); - private readonly environment = inject(ENVIRONMENT); - private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly filesActionsService = inject(FilesActionsService); + private readonly filesUploadService = inject(FilesUploadService); private readonly toastService = inject(ToastService); private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); - private readonly platformId = inject(PLATFORM_ID); - private readonly isBrowser = isPlatformBrowser(this.platformId); - - private readonly webUrl = this.environment.webUrl; - private readonly apiDomainUrl = this.environment.apiDomainUrl; + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly actions = createDispatchMap({ createFolder: CreateFolder, @@ -160,13 +140,17 @@ export class FilesComponent { readonly isFilesLoading = select(FilesSelectors.isFilesLoading); readonly currentFolder = select(FilesSelectors.getCurrentFolder); readonly provider = select(FilesSelectors.getProvider); - readonly resourceDetails = select(CurrentResourceSelectors.getResourceDetails); readonly resourceMetadata = select(CurrentResourceSelectors.getCurrentResource); readonly rootFolders = select(FilesSelectors.getRootFolders); readonly isRootFoldersLoading = select(FilesSelectors.isRootFoldersLoading); readonly configuredStorageAddons = select(FilesSelectors.getConfiguredStorageAddons); readonly isConfiguredStorageAddonsLoading = select(FilesSelectors.isConfiguredStorageAddonsLoading); readonly supportedFeatures = select(FilesSelectors.getStorageSupportedFeatures); + readonly hasWriteAccess = select(CurrentResourceSelectors.hasResourceWriteAccess); + readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess); + readonly currentResourceType = computed( + () => (this.resourceMetadata()?.type as CurrentResourceType) ?? CurrentResourceType.Projects + ); readonly isGoogleDrive = signal(false); readonly accountId = signal(''); @@ -193,17 +177,12 @@ export class FilesComponent { allowRevisions = false; filesSelection: FileModel[] = []; - private readonly urlMap = new Map([ - [ResourceType.Project, 'nodes'], - [ResourceType.Registration, 'registrations'], - ]); - readonly allowedMenuActions = computed(() => { const provider = this.provider(); const supportedFeatures = this.supportedFeatures()[provider] || []; const hasViewOnly = this.hasViewOnly(); const isRegistration = this.resourceType() === ResourceType.Registration; - const menuMap = this.mapMenuActions(supportedFeatures); + const menuMap = mapMenuActions(supportedFeatures); const result: Record = { ...menuMap }; @@ -219,15 +198,8 @@ export class FilesComponent { }); readonly rootFoldersOptions = computed(() => { - const rootFolders = this.rootFolders(); - const addons = this.configuredStorageAddons(); - if (rootFolders && addons) { - return rootFolders.map((folder) => ({ - label: this.getAddonName(addons, folder.provider), - folder: folder, - })); - } - return []; + const osfLabel = this.translateService.instant('files.storageLocation'); + return mapRootFoldersToStorageLabels(this.rootFolders(), this.configuredStorageAddons(), osfLabel); }); resourceType = signal( @@ -235,16 +207,7 @@ export class FilesComponent { ); readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); - - readonly canEdit = computed(() => { - const details = this.resourceDetails(); - const hasAdminOrWrite = details.currentUserPermissions?.some( - (permission) => permission === UserPermissions.Admin || permission === UserPermissions.Write - ); - - return hasAdminOrWrite; - }); - + readonly canEdit = computed(() => this.hasWriteAccess() || this.hasAdminAccess()); readonly isRegistration = computed(() => this.resourceType() === ResourceType.Registration); canUploadFiles = computed( @@ -260,28 +223,33 @@ export class FilesComponent { () => this.isButtonDisabled() || (this.googleFilePickerComponent()?.isGFPDisabled() ?? false) ); - private route = inject(ActivatedRoute); readonly providerName = toSignal( - this.route?.params?.pipe(map((params) => params['fileProvider'])) ?? of('osfstorage') + this.activeRoute?.params?.pipe(map((params) => params['fileProvider'])) ?? of('osfstorage') ); constructor() { + this.initResourceId(); + this.initEffects(); + this.initFilters(); + this.initDestroyHandler(); + } + + private initResourceId(): void { this.activeRoute.parent?.parent?.parent?.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { if (params['id']) { this.resourceId.set(params['id']); } }); + } + private initEffects(): void { effect(() => { const resourceId = this.resourceId(); + if (!resourceId) return; - const resourcePath = this.urlMap.get(this.resourceType()!); - const folderLink = `${this.apiDomainUrl}/v2/${resourcePath}/${resourceId}/files/`; - const iriLink = `${this.webUrl}/${resourceId}`; - - this.actions.getResourceDetails(resourceId, this.resourceType()!); - this.actions.getRootFolders(folderLink); - this.actions.getConfiguredStorageAddons(iriLink); + this.actions.getResourceDetails(resourceId, this.resourceType()); + this.actions.getRootFolders(resourceId, this.resourceType()); + this.actions.getConfiguredStorageAddons(resourceId); }); effect(() => { @@ -336,7 +304,9 @@ export class FilesComponent { this.updateFilesList(); } }); + } + private initFilters(): void { this.searchControl.valueChanges .pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged(), debounceTime(500)) .subscribe((searchText) => { @@ -350,7 +320,9 @@ export class FilesComponent { this.updateFilesList(); }); + } + private initDestroyHandler(): void { this.destroyRef.onDestroy(() => { if (this.isBrowser) { this.actions.resetState(); @@ -358,103 +330,40 @@ export class FilesComponent { }); } - onLoadFiles(event: { link: string; page: number }) { + onLoadFiles(event: FilePageLinkModel) { this.actions.getFiles(event.link, event.page); } uploadFiles(files: File | File[]): void { - const currentFolder = this.currentFolder(); - const uploadLink = currentFolder?.links.upload; + const uploadLink = this.currentFolder()?.links.upload; if (!uploadLink) return; - const fileArray = Array.isArray(files) ? files : [files]; - if (fileArray.length === 0) return; - - this.fileName.set(fileArray.length === 1 ? fileArray[0].name : `${fileArray.length} files`); - this.fileIsUploading.set(true); - this.progress.set(0); - - let completedUploads = 0; - const totalFiles = fileArray.length; - const conflictFiles: { file: File; link: string }[] = []; - - fileArray.forEach((file) => { - this.filesService - .uploadFile(file, uploadLink) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((err) => { - const conflictLink = err.error?.data?.links?.upload; - if (err.status === 409 && conflictLink) { - if (this.allowRevisions) { - return this.filesService.uploadFile(file, conflictLink, true); - } else { - conflictFiles.push({ file, link: conflictLink }); - } - } - return of(new HttpResponse()); - }) - ) - .subscribe((event) => { - if (event.type === HttpEventType.UploadProgress && event.total) { - const progressPercentage = Math.round((event.loaded / event.total) * 100); - if (totalFiles === 1) { - this.progress.set(progressPercentage); - } - } - - if (event.type === HttpEventType.Response) { - completedUploads++; - - if (totalFiles > 1) { - const progressPercentage = Math.round((completedUploads / totalFiles) * 100); - this.progress.set(progressPercentage); - } - - if (completedUploads === totalFiles) { - if (conflictFiles.length > 0) { - this.openReplaceFileDialog(conflictFiles); - } else { - this.completeUpload(); - } - } - } - }); - }); - } - - private openReplaceFileDialog(conflictFiles: { file: File; link: string }[]) { - this.customConfirmationService.confirmDelete({ - headerKey: conflictFiles.length > 1 ? 'files.dialogs.replaceFile.multiple' : 'files.dialogs.replaceFile.single', - messageKey: 'files.dialogs.replaceFile.message', - messageParams: { - name: conflictFiles.map((c) => c.file.name).join(', '), + this.filesUploadService.uploadFiles({ + files, + uploadLink, + allowRevisions: this.allowRevisions, + onStart: (fileName) => { + this.fileName.set(fileName); + this.fileIsUploading.set(true); + this.progress.set(0); }, - acceptLabelKey: 'common.buttons.replace', - onConfirm: () => { - const replaceRequests$ = conflictFiles.map(({ file, link }) => - this.filesService.uploadFile(file, link, true).pipe( - takeUntilDestroyed(this.destroyRef), - catchError(() => of(null)) - ) - ); - - forkJoin(replaceRequests$).subscribe({ - next: () => this.completeUpload(), - }); + onProgress: (progress) => { + this.progress.set(progress); + }, + onComplete: () => { + this.fileIsUploading.set(false); + this.fileName.set(''); + this.updateFilesList(); }, }); } - private completeUpload(): void { - this.fileIsUploading.set(false); - this.fileName.set(''); - this.updateFilesList(); - } - onFileTreeSelected(file: FileModel): void { - this.filesSelection.push(file); - this.filesSelection = [...new Set(this.filesSelection)]; + if (this.filesSelection.some((selectedFile) => selectedFile.id === file.id)) { + return; + } + + this.filesSelection = [...this.filesSelection, file]; } onFileTreeUnselected(file: FileModel): void { @@ -466,29 +375,12 @@ export class FilesComponent { } onDeleteSelected(): void { - if (!this.filesSelection.length) return; - - this.customConfirmationService.confirmDelete({ - headerKey: 'files.dialogs.deleteMultipleItems.title', - messageKey: 'files.dialogs.deleteMultipleItems.message', - messageParams: { - name: this.filesSelection.map((f) => f.name).join(', '), - }, - acceptLabelKey: 'common.buttons.delete', - onConfirm: () => { - const deleteRequests$ = this.filesSelection.map((file) => - this.actions.deleteEntry(file.links.delete).pipe(catchError(() => of(null))) - ); - - forkJoin(deleteRequests$) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.toastService.showSuccess('files.dialogs.deleteFile.success'); - this.filesSelection = []; - this.updateFilesList(); - }, - }); + this.filesActionsService.deleteSelected({ + files: this.filesSelection, + deleteEntry: (link) => this.actions.deleteEntry(link), + onSuccess: () => { + this.filesSelection = []; + this.updateFilesList(); }, }); } @@ -517,30 +409,31 @@ export class FilesComponent { this.uploadFiles(Array.from(files)); } - moveFiles(files: FileModel[], action: string): void { + moveFiles(files: FileModel[], action: 'move' | 'copy'): void { const currentFolder = this.currentFolder(); this.actions.setMoveDialogCurrentFolder(currentFolder); this.isMoveDialogOpened.set(true); - this.customDialogService - .open(MoveFileDialogComponent, { - header: 'files.dialogs.moveFile.title', - width: '552px', - data: { - files: files, - resourceId: this.resourceId(), - action: action, - storageProvider: this.provider(), - foldersStack: this.foldersStack, - initialFolder: structuredClone(this.currentFolder()), - }, + + this.filesActionsService + .openMoveDialog({ + files, + action, + resourceId: this.resourceId(), + storageProvider: this.provider(), + foldersStack: this.foldersStack, + initialFolder: currentFolder, }) - .onClose.subscribe((result) => { - if (result) { - this.filesSelection = []; - } - this.isMoveDialogOpened.set(false); - this.resetProvider(); - }); + .pipe( + tap((result) => { + if (result) { + this.filesSelection = []; + } + this.isMoveDialogOpened.set(false); + this.resetProvider(); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } resetProvider() { @@ -563,20 +456,18 @@ export class FilesComponent { if (!newFolderLink) return; - this.customDialogService - .open(CreateFolderDialogComponent, { - header: 'files.dialogs.createFolder.title', - width: '448px', + this.filesActionsService + .openCreateFolderDialog({ + newFolderLink, + createFolder: (link, folderName) => this.actions.createFolder(link, folderName), }) - .onClose.pipe( - filter((folderName: string) => !!folderName), - switchMap((folderName: string) => this.actions.createFolder(newFolderLink, folderName)), - take(1), + .pipe( + tap(() => this.toastService.showSuccess('files.dialogs.createFolder.success')), finalize(() => { this.updateFilesList(); this.fileIsUploading.set(false); - this.toastService.showSuccess('files.dialogs.createFolder.success'); - }) + }), + takeUntilDestroyed(this.destroyRef) ) .subscribe(); } @@ -626,7 +517,7 @@ export class FilesComponent { }); } - renameEntry(event: { newName: string; link: string }) { + renameEntry(event: RenamedFileLinkModel) { const { newName, link } = event; this.actions.renameEntry(link, newName).subscribe(() => { this.toastService.showSuccess('files.dialogs.renameFile.success'); @@ -635,21 +526,30 @@ export class FilesComponent { } navigateToFile(file: FileModel) { - const extras = this.hasViewOnly() - ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } - : undefined; + if (file.guid) { + this.openFile(file.guid); + return; + } - const url = this.router.serializeUrl(this.router.createUrlTree(['/', file.guid], extras)); + this.filesService.getFileGuid(file.id).subscribe((file) => { + if (file.guid) { + this.openFile(file.guid); + } + }); + } - window.open(url, '_blank'); + handleRootFolderChange(selectedFolder: FileLabelModel) { + const provider = selectedFolder.folder?.provider; + const resourceId = this.resourceId(); + this.router.navigate([`/${resourceId}/files`, provider], { queryParamsHandling: 'preserve' }); } - getAddonName(addons: ConfiguredAddonModel[], provider: string): string { - if (provider === FileProvider.OsfStorage) { - return this.translateService.instant('files.storageLocation'); - } else { - return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; - } + private openFile(guid: string): void { + const extras = this.hasViewOnly() + ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } + : undefined; + + window.open(this.router.serializeUrl(this.router.createUrlTree(['/', guid], extras)), '_blank'); } private setGoogleAccountId(): void { @@ -657,38 +557,7 @@ export class FilesComponent { const googleDrive = addons?.find((addon) => addon.externalServiceName === FileProvider.GoogleDrive); if (googleDrive) { this.accountId.set(googleDrive.baseAccountId); - this.selectedRootFolder.set({ - itemId: googleDrive.selectedStorageItemId, - }); + this.selectedRootFolder.set({ itemId: googleDrive.selectedStorageItemId }); } } - - 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.DeleteFiles) && - supportedFeatures.includes(SupportedFeature.AddUpdateFiles), - [FileMenuType.Embed]: true, - [FileMenuType.Share]: true, - [FileMenuType.Copy]: true, - }; - } - - openGoogleFilePicker(): void { - this.googleFilePickerComponent()?.createPicker(); - this.updateFilesList(); - } - - onUpdateFoldersStack(newStack: FileFolderModel[]): void { - this.foldersStack = [...newStack]; - } - - handleRootFolderChange(selectedFolder: FileLabelModel) { - const provider = selectedFolder.folder?.provider; - const resourceId = this.resourceId(); - this.router.navigate([`/${resourceId}/files`, provider], { queryParamsHandling: 'preserve' }); - } } diff --git a/src/app/features/files/services/files-actions.service.ts b/src/app/features/files/services/files-actions.service.ts new file mode 100644 index 000000000..f128afa1c --- /dev/null +++ b/src/app/features/files/services/files-actions.service.ts @@ -0,0 +1,73 @@ +import { catchError, filter, forkJoin, Observable, of, switchMap, take } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { CreateFolderDialogComponent } from '../components/create-folder-dialog/create-folder-dialog.component'; +import { MoveFileDialogComponent } from '../components/move-file-dialog/move-file-dialog.component'; +import { CreateFolderOptions, DeleteSelectedOptions, MoveFilesOptions } from '../models/files-actions-options.model'; + +@Injectable() +export class FilesActionsService { + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + + deleteSelected(options: DeleteSelectedOptions): void { + if (!options.files.length) return; + + const fileNames = options.files.map((f) => f.name).join(', '); + + this.customConfirmationService.confirmDelete({ + headerKey: 'files.dialogs.deleteMultipleItems.title', + messageKey: 'files.dialogs.deleteMultipleItems.message', + messageParams: { name: fileNames }, + acceptLabelKey: 'common.buttons.delete', + onConfirm: () => { + const deleteRequests$ = options.files.map((file) => + options.deleteEntry(file.links.delete).pipe(catchError(() => of(null))) + ); + + forkJoin(deleteRequests$).subscribe({ + next: () => { + this.toastService.showSuccess('files.dialogs.deleteFile.success'); + options.onSuccess(); + }, + }); + }, + }); + } + + openMoveDialog(options: MoveFilesOptions): Observable { + return this.customDialogService + .open(MoveFileDialogComponent, { + header: 'files.dialogs.moveFile.title', + width: '552px', + data: { + files: options.files, + resourceId: options.resourceId, + action: options.action, + storageProvider: options.storageProvider, + foldersStack: options.foldersStack, + initialFolder: structuredClone(options.initialFolder), + }, + }) + .onClose.pipe(take(1)); + } + + openCreateFolderDialog(options: CreateFolderOptions): Observable { + return this.customDialogService + .open(CreateFolderDialogComponent, { + header: 'files.dialogs.createFolder.title', + width: '448px', + }) + .onClose.pipe( + filter((folderName: string) => !!folderName), + switchMap((folderName) => options.createFolder(options.newFolderLink, folderName)), + take(1) + ); + } +} diff --git a/src/app/features/files/services/files-upload.service.ts b/src/app/features/files/services/files-upload.service.ts new file mode 100644 index 000000000..5039d485c --- /dev/null +++ b/src/app/features/files/services/files-upload.service.ts @@ -0,0 +1,103 @@ +import { catchError, forkJoin, of } from 'rxjs'; + +import { HttpEvent, HttpEventType, HttpResponse } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { FileLinkModel } from '@osf/shared/models/files/file-link.model'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { FilesService } from '@osf/shared/services/files.service'; + +import { UploadFilesOptions, UploadState } from '../models/files-upload-options.model'; + +@Injectable() +export class FilesUploadService { + private readonly filesService = inject(FilesService); + private readonly customConfirmationService = inject(CustomConfirmationService); + + uploadFiles(options: UploadFilesOptions): void { + const fileArray = Array.isArray(options.files) ? options.files : [options.files]; + if (!fileArray.length) return; + + const uploadLabel = fileArray.length === 1 ? fileArray[0].name : `${fileArray.length} files`; + + options.onStart(uploadLabel); + options.onProgress(0); + + const state: UploadState = { + completedUploads: 0, + totalFiles: fileArray.length, + conflictFiles: [], + }; + + fileArray.forEach((file) => { + this.createUploadRequest(file, options, state).subscribe((event) => { + this.handleUploadEvent(event, options, state); + }); + }); + } + + private createUploadRequest(file: File, options: UploadFilesOptions, state: UploadState) { + return this.filesService.uploadFile(file, options.uploadLink).pipe( + catchError((err) => { + const conflictLink = err.error?.data?.links?.upload; + if (err.status === 409 && conflictLink) { + if (options.allowRevisions) { + return this.filesService.uploadFile(file, conflictLink, true); + } + + state.conflictFiles.push({ file, link: conflictLink }); + } + + return of(new HttpResponse()); + }) + ); + } + + private handleUploadEvent(event: HttpEvent, options: UploadFilesOptions, state: UploadState): void { + if (event.type === HttpEventType.UploadProgress && event.total && state.totalFiles === 1) { + options.onProgress(Math.round(((event.loaded ?? 0) / event.total) * 100)); + } + + if (event.type !== HttpEventType.Response) { + return; + } + + state.completedUploads++; + + if (state.totalFiles > 1) { + options.onProgress(Math.round((state.completedUploads / state.totalFiles) * 100)); + } + + if (state.completedUploads !== state.totalFiles) { + return; + } + + if (state.conflictFiles.length > 0) { + this.openReplaceFileDialog(state.conflictFiles, options.onComplete); + return; + } + + options.onComplete(); + } + + private openReplaceFileDialog(conflictFiles: FileLinkModel[], onComplete: () => void): void { + const headerKey = + conflictFiles.length > 1 ? 'files.dialogs.replaceFile.multiple' : 'files.dialogs.replaceFile.single'; + + this.customConfirmationService.confirmDelete({ + headerKey, + messageKey: 'files.dialogs.replaceFile.message', + messageParams: { name: conflictFiles.map((c) => c.file.name).join(', ') }, + acceptLabelKey: 'common.buttons.replace', + onConfirm: () => { + const replaceRequests$ = conflictFiles.map(({ file, link }) => + this.filesService.uploadFile(file, link, true).pipe(catchError(() => of(null))) + ); + + forkJoin(replaceRequests$).subscribe({ + next: () => onComplete(), + }); + }, + }); + } +} diff --git a/src/app/features/files/store/files.actions.ts b/src/app/features/files/store/files.actions.ts index 999863b71..0236ee41d 100644 --- a/src/app/features/files/store/files.actions.ts +++ b/src/app/features/files/store/files.actions.ts @@ -1,6 +1,7 @@ import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { ResourceType } from '@shared/enums/resource-type.enum'; -import { PatchFileMetadata } from '../models'; +import { PatchFileMetadata } from '../models/patch-file-metadata.model'; export class GetFiles { static readonly type = '[Files] Get Files'; @@ -131,25 +132,31 @@ export class DeleteEntry { export class GetRootFolders { static readonly type = '[Files] Get Folders'; - constructor(public folderLink: string) {} + constructor( + public resourceId: string, + public resourceType: ResourceType + ) {} } export class GetConfiguredStorageAddons { static readonly type = '[Files] Get ConfiguredStorageAddons'; - constructor(public resourceUri: string) {} + constructor(public resourceId: string) {} } export class GetMoveDialogRootFolders { static readonly type = '[Files] Get Move Dialog Folders'; - constructor(public folderLink: string) {} + constructor( + public resourceId: string, + public resourceType: ResourceType + ) {} } export class GetMoveDialogConfiguredStorageAddons { static readonly type = '[Files] Get Move Dialog ConfiguredStorageAddons'; - constructor(public resourceUri: string) {} + constructor(public resourceId: string) {} } export class GetStorageSupportedFeatures { diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index 518bed572..9c5dcb4b1 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -8,7 +8,8 @@ import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; import { FileProvider } from '../constants'; -import { OsfFileCustomMetadata, OsfFileRevision } from '../models'; +import { OsfFileCustomMetadata } from '../models/file-custom-metadata.model'; +import { OsfFileRevision } from '../models/file-revisions.model'; export interface FilesStateModel { files: AsyncStateWithTotalCount; diff --git a/src/app/features/files/store/files.selectors.ts b/src/app/features/files/store/files.selectors.ts index 81e0cca87..d4af2d066 100644 --- a/src/app/features/files/store/files.selectors.ts +++ b/src/app/features/files/store/files.selectors.ts @@ -8,7 +8,8 @@ import { FileDetailsModel, FileModel } from '@osf/shared/models/files/file.model import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { ResourceMetadata } from '@osf/shared/models/resource-metadata.model'; -import { OsfFileCustomMetadata, OsfFileRevision } from '../models'; +import { OsfFileCustomMetadata } from '../models/file-custom-metadata.model'; +import { OsfFileRevision } from '../models/file-revisions.model'; import { FilesStateModel } from './files.model'; import { FilesState } from './files.state'; diff --git a/src/app/features/files/store/files.state.ts b/src/app/features/files/store/files.state.ts index 52e6e31a8..ea2c68229 100644 --- a/src/app/features/files/store/files.state.ts +++ b/src/app/features/files/store/files.state.ts @@ -7,7 +7,6 @@ import { inject, Injectable } from '@angular/core'; import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { FilesService } from '@osf/shared/services/files.service'; -import { ToastService } from '@osf/shared/services/toast.service'; import { MapResourceMetadata } from '../mappers'; @@ -45,7 +44,6 @@ import { FILES_STATE_DEFAULTS, FilesStateModel } from './files.model'; }) export class FilesState { filesService = inject(FilesService); - toastService = inject(ToastService); @Action(GetFiles) getFiles(ctx: StateContext, action: GetFiles) { @@ -262,7 +260,7 @@ export class FilesState { getRootFolders(ctx: StateContext, action: GetRootFolders) { const state = ctx.getState(); ctx.patchState({ rootFolders: { ...state.rootFolders, isLoading: true } }); - return this.filesService.getFolders(action.folderLink).pipe( + return this.filesService.getRootFolders(action.resourceId, action.resourceType).pipe( tap((response) => ctx.patchState({ rootFolders: { @@ -282,7 +280,7 @@ export class FilesState { const state = ctx.getState(); ctx.patchState({ moveDialogRootFolders: { ...state.moveDialogRootFolders, isLoading: true } }); - return this.filesService.getFolders(action.folderLink).pipe( + return this.filesService.getRootFolders(action.resourceId, action.resourceType).pipe( tap((response) => ctx.patchState({ moveDialogRootFolders: { @@ -302,7 +300,7 @@ export class FilesState { const state = ctx.getState(); ctx.patchState({ configuredStorageAddons: { ...state.configuredStorageAddons, isLoading: true } }); - return this.filesService.getConfiguredStorageAddons(action.resourceUri).pipe( + return this.filesService.getConfiguredStorageAddons(action.resourceId).pipe( tap((addons) => ctx.patchState({ configuredStorageAddons: { @@ -326,7 +324,7 @@ export class FilesState { moveDialogConfiguredStorageAddons: { ...state.moveDialogConfiguredStorageAddons, isLoading: true }, }); - return this.filesService.getConfiguredStorageAddons(action.resourceUri).pipe( + return this.filesService.getConfiguredStorageAddons(action.resourceId).pipe( tap((addons) => ctx.patchState({ moveDialogConfiguredStorageAddons: { diff --git a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts index 032e378f3..58b859259 100644 --- a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts +++ b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts @@ -23,6 +23,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { SocialShareService } from '@osf/shared/services/social-share.service'; import { CEDAR_CONFIG, CEDAR_VIEWER_CONFIG } from '../../constants'; import { CedarMetadataHelper } from '../../helpers'; @@ -62,6 +63,7 @@ export class CedarTemplateFormComponent { private route = inject(ActivatedRoute); readonly environment = inject(ENVIRONMENT); + private readonly socialShareService = inject(SocialShareService); readonly recordId = signal(''); readonly downloadUrl = signal(''); @@ -184,18 +186,18 @@ export class CedarTemplateFormComponent { handleEmailShare(): void { const url = window.location.href; - window.location.href = `mailto:?subject=${this.schemaName()}&body=${url}`; + window.location.href = this.socialShareService.getEmailLink(this.schemaName(), url); } handleXShare(): void { const url = window.location.href; - const link = `https://x.com/intent/tweet?url=${url}&text=${this.schemaName()}&via=OSFramework`; + const link = this.socialShareService.getXLink(this.schemaName(), url); window.open(link, '_blank', 'noopener,noreferrer'); } handleFacebookShare(): void { const url = window.location.href; - const link = `https://www.facebook.com/sharer/sharer.php?u=${url}`; + const link = this.socialShareService.getFacebookLink(url); window.open(link, '_blank', 'noopener,noreferrer'); } } diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.html b/src/app/features/preprints/components/stepper/file-step/file-step.component.html index 8d9c845ab..425308db6 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.html +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.html @@ -95,6 +95,7 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

[files]="projectFiles()" [totalCount]="filesTotalCount()" [storage]="null" + [resourceType]="resourceType" [selectionMode]="null" [isLoading]="areProjectFilesLoading() || isCurrentFolderLoading()" [resourceId]="selectedProjectId()!" diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts index 48f87991d..dbf74f832 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts @@ -43,9 +43,11 @@ import { import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { ClearFileDirective } from '@osf/shared/directives/clear-file.directive'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; import { ToastService } from '@osf/shared/services/toast.service'; @Component({ @@ -86,6 +88,7 @@ export class FileStepComponent implements OnInit { }); readonly PreprintFileSource = PreprintFileSource; + readonly resourceType = CurrentResourceType.Preprints; provider = input.required(); showDeleteButton = input(false); @@ -225,7 +228,7 @@ export class FileStepComponent implements OnInit { this.actions.getProjectFilesByLink(folder.links.filesLink, 1); } - onLoadFiles(event: { link: string; page: number }) { + onLoadFiles(event: FilePageLinkModel) { this.actions.getProjectFilesByLink(event.link, event.page); } } diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.html b/src/app/features/project/overview/components/files-widget/files-widget.component.html index bc85b93cc..443aa125a 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.html +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.html @@ -39,6 +39,7 @@

{{ 'project.overview.files.filesPreview' | translate }}

[storage]="currentRootFolder()!" [isLoading]="isFilesLoading() || isStorageLoading" [resourceId]="selectedRoot!" + [resourceType]="resourceType" [provider]="provider()" [selectionMode]="null" [scrollHeight]="'300px'" diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.ts b/src/app/features/project/overview/components/files-widget/files-widget.component.ts index 535b01a43..3f90d2cb8 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.ts +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; import { TabsModule } from 'primeng/tabs'; @@ -20,7 +20,6 @@ import { } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; import { FileProvider } from '@osf/features/files/constants'; import { FilesSelectors, @@ -32,14 +31,17 @@ import { } from '@osf/features/files/store'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { SelectComponent } from '@osf/shared/components/select/select.component'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { mapRootFoldersToStorageLabels } from '@osf/shared/helpers/storage-addon-options.helper'; import { Primitive } from '@osf/shared/helpers/types.helper'; -import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { SelectOption } from '@osf/shared/models/select-option.model'; +import { FilesService } from '@osf/shared/services/files.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; @Component({ @@ -56,9 +58,10 @@ export class FilesWidgetComponent { router = inject(Router); activeRoute = inject(ActivatedRoute); - private readonly environment = inject(ENVIRONMENT); private readonly destroyRef = inject(DestroyRef); private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); + private readonly filesService = inject(FilesService); + private readonly translateService = inject(TranslateService); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); @@ -75,7 +78,7 @@ export class FilesWidgetComponent { currentRootFolder = model(null); pageNumber = signal(1); - readonly osfStorageLabel = 'OSF Storage'; + readonly resourceType = CurrentResourceType.Projects; readonly options = computed(() => { const components = this.components().filter((component) => this.rootOption().value !== component.id); @@ -83,15 +86,8 @@ export class FilesWidgetComponent { }); readonly storageAddons = computed(() => { - const rootFolders = this.rootFolders(); - const addons = this.configuredStorageAddons(); - if (rootFolders && addons) { - return rootFolders.map((folder) => ({ - label: this.getAddonName(addons, folder.provider), - folder: folder, - })); - } - return []; + const osfLabel = this.translateService.instant('files.storageLocation'); + return mapRootFoldersToStorageLabels(this.rootFolders(), this.configuredStorageAddons(), osfLabel); }); readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); @@ -129,7 +125,7 @@ export class FilesWidgetComponent { const osfRootFolder = rootFolders.find((folder) => folder.provider === FileProvider.OsfStorage); if (osfRootFolder) { this.currentRootFolder.set({ - label: this.osfStorageLabel, + label: this.translateService.instant('files.storageLocation'), folder: osfRootFolder, }); } @@ -160,11 +156,8 @@ export class FilesWidgetComponent { } private getStorageAddons(projectId: string) { - const resourcePath = 'nodes'; - const folderLink = `${this.environment.apiDomainUrl}/v2/${resourcePath}/${projectId}/files/`; - const iriLink = `${this.environment.webUrl}/${projectId}`; - this.actions.getRootFolders(folderLink); - this.actions.getConfiguredStorageAddons(iriLink); + this.actions.getRootFolders(projectId, ResourceType.Project); + this.actions.getConfiguredStorageAddons(projectId); } private flatComponents( @@ -205,14 +198,6 @@ export class FilesWidgetComponent { }, []); } - private getAddonName(addons: ConfiguredAddonModel[], provider: string): string { - if (provider === FileProvider.OsfStorage) { - return this.osfStorageLabel; - } else { - return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; - } - } - onChangeProject(value: Primitive) { this.getStorageAddons(value as string); } @@ -225,16 +210,27 @@ export class FilesWidgetComponent { } navigateToFile(file: FileModel) { + if (file.guid) { + this.openFile(file.guid); + return; + } + + this.filesService.getFileGuid(file.id).subscribe((file) => { + if (file.guid) { + this.openFile(file.guid); + } + }); + } + + private openFile(guid: string): void { const extras = this.hasViewOnly() ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } : undefined; - const url = this.router.serializeUrl(this.router.createUrlTree(['/', file.guid], extras)); - - window.open(url, '_blank'); + window.open(this.router.serializeUrl(this.router.createUrlTree(['/', guid], extras)), '_blank'); } - onLoadFiles(event: { link: string; page: number }) { + onLoadFiles(event: FilePageLinkModel) { this.actions.getFiles(event.link, event.page); } 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 951fcac20..7a308fdd0 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 @@ -49,6 +49,7 @@ [scrollHeight]="'500px'" [viewOnly]="filesViewOnly()" [resourceId]="projectId()" + [resourceType]="resourceType" [provider]="provider()" [selectedFiles]="filesSelection" [isDraftResource]="true" diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index 1557f575a..415b22bc7 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -2,8 +2,6 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { TreeDragDropService } from 'primeng/api'; - import { of, Subject } from 'rxjs'; import { Mock } from 'vitest'; @@ -70,7 +68,6 @@ describe('FilesControlComponent', () => { MockProvider(CustomConfirmationService), MockProvider(FilesService, mockFilesService), MockProvider(CustomDialogService, mockDialogService), - MockProvider(TreeDragDropService), provideMockStore({ signals: [ { selector: RegistriesSelectors.getFiles, value: [] }, 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 fd367abbc..1606151b4 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 @@ -2,7 +2,6 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; import { filter, finalize, switchMap, take } from 'rxjs'; @@ -17,6 +16,8 @@ import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { FILE_SIZE_LIMIT } from '@osf/shared/constants/files-limits.const'; import { ClearFileDirective } from '@osf/shared/directives/clear-file.directive'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -46,7 +47,6 @@ import { templateUrl: './files-control.component.html', styleUrl: './files-control.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TreeDragDropService], }) export class FilesControlComponent { attachedFiles = input.required[]>(); @@ -71,6 +71,7 @@ export class FilesControlComponent { readonly progress = signal(0); readonly fileName = signal(''); readonly dataLoaded = signal(false); + readonly resourceType = CurrentResourceType.Registrations; fileIsUploading = signal(false); filesSelection: FileModel[] = []; @@ -180,7 +181,7 @@ export class FilesControlComponent { this.filesSelection = [...new Set(this.filesSelection)]; } - onLoadFiles(event: { link: string; page: number }) { + onLoadFiles(event: FilePageLinkModel) { this.actions.getFiles(event.link, event.page); } diff --git a/src/app/shared/components/file-menu/file-menu.component.html b/src/app/shared/components/file-menu/file-menu.component.html index cb17cb23f..f5b180995 100644 --- a/src/app/shared/components/file-menu/file-menu.component.html +++ b/src/app/shared/components/file-menu/file-menu.component.html @@ -6,7 +6,7 @@ variant="text" [raised]="true" icon="fas fa-ellipsis-v" - (click)="onMenuToggle($event)" + (onClick)="onMenuToggle($event)" > diff --git a/src/app/shared/components/file-select-destination/file-select-destination.component.ts b/src/app/shared/components/file-select-destination/file-select-destination.component.ts index b88134e4c..ab27aaf53 100644 --- a/src/app/shared/components/file-select-destination/file-select-destination.component.ts +++ b/src/app/shared/components/file-select-destination/file-select-destination.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; @@ -23,8 +23,6 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { FileProvider } from '@osf/features/files/constants'; import { FilesSelectors, GetMoveDialogConfiguredStorageAddons, @@ -34,9 +32,10 @@ import { SetMoveDialogCurrentFolder, } from '@osf/features/files/store'; import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { mapRootFoldersToStorageLabels } from '@osf/shared/helpers/storage-addon-options.helper'; import { Primitive } from '@osf/shared/helpers/types.helper'; -import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; import { SelectOption } from '@osf/shared/models/select-option.model'; @@ -58,8 +57,8 @@ export class FileSelectDestinationComponent implements OnInit { selectProject = output(); selectStorage = output(); - private readonly environment = inject(ENVIRONMENT); private readonly destroyRef = inject(DestroyRef); + private readonly translateService = inject(TranslateService); readonly rootFolders = select(FilesSelectors.getMoveDialogRootFolders); readonly isRootFoldersLoading = select(FilesSelectors.isMoveDialogRootFoldersLoading); @@ -77,7 +76,6 @@ export class FileSelectDestinationComponent implements OnInit { setCurrentProvider: SetCurrentProvider, }); - readonly osfStorageLabel = 'OSF Storage'; initialSetup = true; currentRootFolder = model(null); selectedProject = computed(() => this.options().find((c) => c.value === this.projectId()) || null); @@ -96,15 +94,8 @@ export class FileSelectDestinationComponent implements OnInit { }); readonly storageAddons = computed(() => { - const rootFolders = this.rootFolders(); - const addons = this.configuredStorageAddons(); - if (rootFolders && addons) { - return rootFolders.map((folder) => ({ - label: this.getAddonName(addons, folder.provider), - folder: folder, - })); - } - return []; + const osfLabel = this.translateService.instant('files.storageLocation'); + return mapRootFoldersToStorageLabels(this.rootFolders(), this.configuredStorageAddons(), osfLabel); }); private getHasWriteAccess = (project: NodeShortInfoModel): boolean => @@ -149,13 +140,9 @@ export class FileSelectDestinationComponent implements OnInit { } private getStorageAddons(projectId: string) { - const resourcePath = 'nodes'; - const folderLink = `${this.environment.apiDomainUrl}/v2/${resourcePath}/${projectId}/files/`; - const iriLink = `${this.environment.webUrl}/${projectId}`; - forkJoin({ - rootFolders: this.actions.getRootFolders(folderLink), - addons: this.actions.getConfiguredStorageAddons(iriLink), + rootFolders: this.actions.getRootFolders(projectId, ResourceType.Project), + addons: this.actions.getConfiguredStorageAddons(projectId), }) .pipe( takeUntilDestroyed(this.destroyRef), @@ -185,14 +172,6 @@ export class FileSelectDestinationComponent implements OnInit { }); } - private getAddonName(addons: ConfiguredAddonModel[], provider: string): string { - if (provider === FileProvider.OsfStorage) { - return this.osfStorageLabel; - } else { - return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; - } - } - private buildOptions(nodes: NodeShortInfoModel[] = [], parentPath = '..'): SelectOption[] { return nodes.reduce((acc, node) => { const pathParts: string[] = []; diff --git a/src/app/shared/components/files-drop-zone/files-drop-zone.component.html b/src/app/shared/components/files-drop-zone/files-drop-zone.component.html new file mode 100644 index 000000000..16f925a91 --- /dev/null +++ b/src/app/shared/components/files-drop-zone/files-drop-zone.component.html @@ -0,0 +1,20 @@ +
+ @if (enabled()) { +
+ @if (isDragOver()) { +
+ +

{{ 'files.dropText' | translate }}

+
+ } +
+ } + + +
diff --git a/src/app/shared/components/files-drop-zone/files-drop-zone.component.scss b/src/app/shared/components/files-drop-zone/files-drop-zone.component.scss new file mode 100644 index 000000000..8e5f40b7b --- /dev/null +++ b/src/app/shared/components/files-drop-zone/files-drop-zone.component.scss @@ -0,0 +1,27 @@ +.drop-zone-container { + position: relative; +} + +.drop-zone { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + color: var(--white); + transition: + background 0.3s ease, + backdrop-filter 0.3s ease; + pointer-events: none; + background: transparent; + + &.active { + backdrop-filter: blur(0.3rem); + background: rgba(132, 174, 210, 0.5); + pointer-events: all; + } +} diff --git a/src/app/shared/components/files-drop-zone/files-drop-zone.component.ts b/src/app/shared/components/files-drop-zone/files-drop-zone.component.ts new file mode 100644 index 000000000..fb8f1e264 --- /dev/null +++ b/src/app/shared/components/files-drop-zone/files-drop-zone.component.ts @@ -0,0 +1,73 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core'; + +@Component({ + selector: 'osf-files-drop-zone', + imports: [TranslatePipe], + templateUrl: './files-drop-zone.component.html', + styleUrl: './files-drop-zone.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilesDropZoneComponent { + enabled = input(true); + filesDropped = output(); + isDragOver = signal(false); + + private dragDepth = 0; + + onDragEnter(event: DragEvent): void { + if (!this.enabled()) { + return; + } + + if (event.dataTransfer?.types?.includes('Files')) { + this.dragDepth += 1; + this.isDragOver.set(true); + } + } + + onDragOver(event: DragEvent): void { + if (!this.enabled()) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + this.isDragOver.set(true); + } + + onDragLeave(event: Event): void { + if (!this.enabled()) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.dragDepth = Math.max(0, this.dragDepth - 1); + if (this.dragDepth === 0) { + this.isDragOver.set(false); + } + } + + onDrop(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.dragDepth = 0; + this.isDragOver.set(false); + + if (!this.enabled()) { + return; + } + + const files = event.dataTransfer?.files; + if (!files || files.length === 0) { + return; + } + + this.filesDropped.emit(Array.from(files)); + } +} diff --git a/src/app/shared/components/files-tree-row/files-tree-row.component.html b/src/app/shared/components/files-tree-row/files-tree-row.component.html new file mode 100644 index 000000000..f08cab3ac --- /dev/null +++ b/src/app/shared/components/files-tree-row/files-tree-row.component.html @@ -0,0 +1,54 @@ +@if (file().previousFolder) { +
+ + + + {{ file().name }} + +
+} @else { +
+
+ +
+ +
+ @if (downloadsCount()) { + {{ downloadsCount() }} {{ 'common.labels.downloads' | translate }} + } +
+ +
+ {{ file().size | fileSize }} +
+ +
+ {{ file().dateModified | date: 'MMM d, y hh:mm a' }} +
+ + @if (showMenu()) { +
+ + +
+ } +
+} diff --git a/src/app/shared/components/files-tree-row/files-tree-row.component.scss b/src/app/shared/components/files-tree-row/files-tree-row.component.scss new file mode 100644 index 000000000..e24a1d7eb --- /dev/null +++ b/src/app/shared/components/files-tree-row/files-tree-row.component.scss @@ -0,0 +1,30 @@ +@use "styles/mixins" as mix; + +.files-table-row { + display: grid; + align-items: center; + grid-template-columns: + minmax(mix.rem(200px), 32rem) minmax(mix.rem(150px), 0.7fr) minmax(mix.rem(100px), 100px) + minmax(mix.rem(150px), 1fr) minmax(mix.rem(50px), 50px); + grid-template-rows: mix.rem(44px); + border-bottom: 1px solid var(--grey-2); + padding: 0 0.75rem; + cursor: pointer; + + &.previous-folder { + grid-template-columns: auto; + } + + &:hover { + background: var(--bg-blue-3); + } + + &:active { + background: var(--bg-blue-2); + } + + .table-cell { + width: 100%; + height: 100%; + } +} diff --git a/src/app/shared/components/files-tree-row/files-tree-row.component.spec.ts b/src/app/shared/components/files-tree-row/files-tree-row.component.spec.ts new file mode 100644 index 000000000..9d2dc991f --- /dev/null +++ b/src/app/shared/components/files-tree-row/files-tree-row.component.spec.ts @@ -0,0 +1,130 @@ +import { MockComponent } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; +import { FileModel } from '@shared/models/files/file.model'; +import { FileMenuFlags } from '@shared/models/files/file-menu-action.model'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { FileMenuComponent } from '../file-menu/file-menu.component'; + +import { FilesTreeRowComponent } from './files-tree-row.component'; + +describe('FilesTreeRowComponent', () => { + let component: FilesTreeRowComponent; + let fixture: ComponentFixture; + + const ALL_MENU_ACTIONS: FileMenuFlags = { + [FileMenuType.Download]: true, + [FileMenuType.Copy]: true, + [FileMenuType.Move]: true, + [FileMenuType.Delete]: true, + [FileMenuType.Rename]: true, + [FileMenuType.Share]: true, + [FileMenuType.Embed]: true, + }; + + function createTestFile(overrides: Partial = {}): FileModel { + return { + id: 'f1', + guid: 'g1', + name: 'test.pdf', + kind: FileKind.File, + path: '/test.pdf', + size: 2048, + materializedPath: '/test.pdf', + dateModified: '2024-06-01T12:00:00.000Z', + extra: { + hashes: { md5: 'm', sha256: 's' }, + downloads: 5, + }, + links: { + info: 'i', + move: 'm', + upload: 'u', + delete: 'd', + download: 'dl', + render: 'r', + html: 'h', + self: 's', + }, + filesLink: null, + previousFolder: false, + provider: 'osfstorage', + ...overrides, + }; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FilesTreeRowComponent, MockComponent(FileMenuComponent)], + providers: [provideOSFCore()], + }); + + fixture = TestBed.createComponent(FilesTreeRowComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('file', createTestFile()); + fixture.componentRef.setInput('allowedMenuActions', ALL_MENU_ACTIONS); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set isFolder when kind is folder', () => { + fixture.componentRef.setInput('file', createTestFile({ kind: FileKind.Folder })); + fixture.detectChanges(); + + expect(component.isFolder()).toBe(true); + }); + + it('should set isFolder false when kind is file', () => { + fixture.componentRef.setInput('file', createTestFile({ kind: FileKind.File })); + fixture.detectChanges(); + + expect(component.isFolder()).toBe(false); + }); + + it('should clear downloadsCount for folder', () => { + fixture.componentRef.setInput( + 'file', + createTestFile({ + kind: FileKind.Folder, + extra: { hashes: { md5: 'm', sha256: 's' }, downloads: 99 }, + }) + ); + fixture.detectChanges(); + + expect(component.downloadsCount()).toBe(''); + }); + + it('should expose downloadsCount for file with downloads', () => { + fixture.componentRef.setInput( + 'file', + createTestFile({ + kind: FileKind.File, + extra: { hashes: { md5: 'm', sha256: 's' }, downloads: 12 }, + }) + ); + fixture.detectChanges(); + + expect(component.downloadsCount()).toBe(12); + }); + + it('should clear downloadsCount when downloads is zero', () => { + fixture.componentRef.setInput( + 'file', + createTestFile({ + kind: FileKind.File, + extra: { hashes: { md5: 'm', sha256: 's' }, downloads: 0 }, + }) + ); + fixture.detectChanges(); + + expect(component.downloadsCount()).toBe(''); + }); +}); diff --git a/src/app/shared/components/files-tree-row/files-tree-row.component.ts b/src/app/shared/components/files-tree-row/files-tree-row.component.ts new file mode 100644 index 000000000..4463ec213 --- /dev/null +++ b/src/app/shared/components/files-tree-row/files-tree-row.component.ts @@ -0,0 +1,45 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileModel } from '@shared/models/files/file.model'; +import { FileMenuAction, FileMenuFlags } from '@shared/models/files/file-menu-action.model'; + +import { FileSizePipe } from '../../pipes/file-size.pipe'; +import { FileMenuComponent } from '../file-menu/file-menu.component'; + +@Component({ + selector: 'osf-files-tree-row', + imports: [Button, DatePipe, FileSizePipe, TranslatePipe, FileMenuComponent, StopPropagationDirective], + templateUrl: './files-tree-row.component.html', + styleUrl: './files-tree-row.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilesTreeRowComponent { + file = input.required(); + hasFoldersStack = input(false); + showMenu = input(false); + allowedMenuActions = input.required(); + + openParentFolder = output(); + openEntry = output(); + menuAction = output(); + + readonly isFolder = computed(() => this.file().kind === FileKind.Folder); + + readonly downloadsCount = computed(() => { + if (!this.file().extra.downloads || this.isFolder()) { + return ''; + } + return this.file().extra.downloads; + }); + + onOpenEntry(): void { + this.openEntry.emit(this.file()); + } +} 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 ba61a2c22..d038cb3fe 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -1,130 +1,52 @@ -
- @if (!hasViewOnly() && supportUpload()) { -
- @if (isDragOver()) { -
- -

{{ 'files.dropText' | translate }}

-
- } -
- } - + @if (isLoading() && !isLoadingMore()) {
} @else { -
-
- - - @if (file.previousFolder) { -
-
- - - {{ file.name ?? '' }} -
-
- } @else { -
-
-
- - {{ file?.name ?? '' }} -
-
- -
- @if (file.extra.downloads) { - {{ - file.kind === 'file' ? file.extra.downloads + ' ' + ('common.labels.downloads' | translate) : '' - }} - } -
- -
- {{ file.size | fileSize }} -
- -
- {{ file.dateModified | date: 'MMM d, y hh:mm a' }} -
- - @if (isSomeFileActionAllowed && !selectedFiles().length) { -
- - -
- } - @if (isDraftResource()) { - - } -
- } -
-
+
+ + + + - @if (!files().length) { -
- @if (hasViewOnly() || !supportUpload()) { + +
+ @if (!canUpload()) {

{{ 'files.emptyState' | translate }}

} @else { -
+

{{ 'files.dropText' | translate }}

}
- } -
+
+
} -
+ diff --git a/src/app/shared/components/files-tree/files-tree.component.scss b/src/app/shared/components/files-tree/files-tree.component.scss index 3984d465f..190e70847 100644 --- a/src/app/shared/components/files-tree/files-tree.component.scss +++ b/src/app/shared/components/files-tree/files-tree.component.scss @@ -1,88 +1,13 @@ -@use "styles/mixins" as mix; - :host { - min-height: 180px; display: flex; flex-direction: column; + min-height: 11.25rem; } .files-table { - display: flex; - flex-direction: column; border: 1px solid var(--grey-2); - border-radius: 8px; - overflow-x: auto; + border-radius: 0.5rem; min-width: 100%; - min-height: 180px; - - &-row { - color: var(--dark-blue-1); - display: grid; - align-items: center; - grid-template-columns: - minmax(mix.rem(200px), 32rem) minmax(mix.rem(150px), 0.7fr) minmax(mix.rem(100px), 100px) - minmax(mix.rem(150px), 1fr) minmax(mix.rem(50px), 50px); - grid-template-rows: mix.rem(44px); - border-bottom: 1px solid var(--grey-2); - padding: 0 mix.rem(12px); - cursor: pointer; - - &:hover { - background: var(--bg-blue-3); - } - - &:active { - background: var(--bg-blue-2); - } - - .table-cell { - width: 100%; - height: 100%; - display: flex; - align-items: center; - } - - > .table-cell:first-child { - max-width: 95%; - } - } -} - -.entry-title { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - max-width: 100%; -} - -.tree-table { - .p-tree { - padding: 0; - } -} - -.drop-zone { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - color: var(--white); - transition: - background 0.3s ease, - backdrop-filter 0.3s ease; - pointer-events: none; - background: transparent; - - &.active { - backdrop-filter: blur(0.3rem); - background: rgba(132, 174, 210, 0.5); - pointer-events: all; - } + min-height: 11.25rem; + overflow-x: auto; } diff --git a/src/app/shared/components/files-tree/files-tree.component.spec.ts b/src/app/shared/components/files-tree/files-tree.component.spec.ts index 59a8bf61c..64d84dcbc 100644 --- a/src/app/shared/components/files-tree/files-tree.component.spec.ts +++ b/src/app/shared/components/files-tree/files-tree.component.spec.ts @@ -2,7 +2,6 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { TreeDragDropService } from 'primeng/api'; -import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; @@ -12,14 +11,12 @@ import { CustomDialogService } from '@osf/shared/services/custom-dialog.service' import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { FilesService } from '@osf/shared/services/files.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileLabelModel } from '@shared/models/files/file-label.model'; import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { DataciteServiceMock, DataciteServiceMockType } from '@testing/providers/datacite.service.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; import { FileMenuComponent } from '../file-menu/file-menu.component'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; @@ -50,15 +47,12 @@ describe('FilesTreeComponent', () => { providers: [ provideOSFCore(), provideRouter([]), - provideMockStore({ - signals: [{ selector: CurrentResourceSelectors.getCurrentResource, value: signal(null) }], - }), MockProvider(DataciteService, dataciteMock), MockProvider(FilesService), MockProvider(ToastService), MockProvider(CustomConfirmationService), MockProvider(CustomDialogService), - TreeDragDropService, + MockProvider(TreeDragDropService), ], }); 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 efe638512..a5e51c8f1 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -1,96 +1,70 @@ -import { select } from '@ngxs/store'; - import { TranslatePipe } from '@ngx-translate/core'; -import { PrimeTemplate, TreeNode } from 'primeng/api'; -import { Button } from 'primeng/button'; -import { Tooltip } from 'primeng/tooltip'; +import { PrimeTemplate, TreeDragDropService } from 'primeng/api'; import { Tree, TreeLazyLoadEvent, TreeNodeDropEvent, TreeNodeSelectEvent } from 'primeng/tree'; -import { Clipboard } from '@angular/cdk/clipboard'; -import { DatePipe, isPlatformBrowser } from '@angular/common'; +import { filter } from 'rxjs'; + +import { isPlatformBrowser } from '@angular/common'; import { - AfterViewInit, ChangeDetectionStrategy, Component, computed, DestroyRef, effect, - ElementRef, - HostBinding, inject, input, - OnDestroy, + model, output, PLATFORM_ID, signal, - viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ConfirmMoveFileDialogComponent } from '@osf/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component'; import { MoveFileDialogComponent } from '@osf/features/files/components/move-file-dialog/move-file-dialog.component'; import { RenameFileDialogComponent } from '@osf/features/files/components/rename-file-dialog/rename-file-dialog.component'; -import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; -import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { FileTreeMapper } from '@osf/shared/mappers/files/file-tree.mapper'; import { FilesMapper } from '@osf/shared/mappers/files/files.mapper'; -import { FileSizePipe } from '@osf/shared/pipes/file-size.pipe'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; +import { RenamedFileLinkModel } from '@osf/shared/models/files/renamed-file-link.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { FilesService } from '@osf/shared/services/files.service'; -import { ToastService } from '@osf/shared/services/toast.service'; +import { FilesShareEmbedService } from '@osf/shared/services/files-share-embed.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FileModel } from '@shared/models/files/file.model'; import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileLabelModel } from '@shared/models/files/file-label.model'; import { FileMenuAction, FileMenuFlags } from '@shared/models/files/file-menu-action.model'; -import { CurrentResourceSelectors } from '@shared/stores/current-resource'; -import { FileMenuComponent } from '../file-menu/file-menu.component'; +import { FilesDropZoneComponent } from '../files-drop-zone/files-drop-zone.component'; +import { FilesTreeRowComponent } from '../files-tree-row/files-tree-row.component'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; -// [NS] Temporary fix -type FileTreeNode = FileModel & TreeNode; - @Component({ selector: 'osf-files-tree', - imports: [ - DatePipe, - FileSizePipe, - PrimeTemplate, - TranslatePipe, - Tree, - LoadingSpinnerComponent, - FileMenuComponent, - StopPropagationDirective, - Button, - Tooltip, - ], + imports: [PrimeTemplate, TranslatePipe, Tree, LoadingSpinnerComponent, FilesDropZoneComponent, FilesTreeRowComponent], + providers: [TreeDragDropService], templateUrl: './files-tree.component.html', styleUrl: './files-tree.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FilesTreeComponent implements OnDestroy, AfterViewInit { - @HostBinding('class') classes = 'relative'; - private dropZoneContainerRef = viewChild('dropZoneContainer'); - readonly filesService = inject(FilesService); - readonly router = inject(Router); - readonly toastService = inject(ToastService); - readonly route = inject(ActivatedRoute); - readonly customConfirmationService = inject(CustomConfirmationService); - readonly customDialogService = inject(CustomDialogService); - readonly dataciteService = inject(DataciteService); - private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); - +export class FilesTreeComponent { private readonly destroyRef = inject(DestroyRef); - private readonly environment = inject(ENVIRONMENT); - private readonly platformId = inject(PLATFORM_ID); - readonly clipboard = inject(Clipboard); + private readonly router = inject(Router); + private readonly filesService = inject(FilesService); + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly customDialogService = inject(CustomDialogService); + private readonly dataciteService = inject(DataciteService); + private readonly filesShareEmbedService = inject(FilesShareEmbedService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); files = input.required(); totalCount = input(0); @@ -98,6 +72,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { currentFolder = input.required(); storage = input.required(); resourceId = input.required(); + resourceType = input(CurrentResourceType.Projects); viewOnly = input(true); provider = input(); allowedMenuActions = input({} as FileMenuFlags); @@ -112,60 +87,44 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { setCurrentFolder = output(); setMoveDialogCurrentFolder = output(); deleteEntryAction = output(); - renameEntryAction = output<{ newName: string; link: string }>(); - loadFiles = output<{ link: string; page: number }>(); + renameEntryAction = output(); + loadFiles = output(); selectFile = output(); unselectFile = output(); clearSelection = output(); - updateFoldersStack = output(); resetFilesProvider = output(); - readonly resourceMetadata = select(CurrentResourceSelectors.getCurrentResource); - - foldersStack: FileFolderModel[] = []; + foldersStack = model([]); lastSelectedFile: FileModel | null = null; itemsPerPage = 10; virtualScrollItemSize = 46; - isDragOver = signal(false); isLoadingMore = signal(false); - hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router) || this.viewOnly()); - visibleFilesCount = computed((): number => { - const height = parseInt(this.scrollHeight(), 10); - return Math.ceil(height / this.virtualScrollItemSize); - }); - - get isSomeFileActionAllowed(): boolean { - return Object.keys(this.allowedMenuActions()).length > 0; - } + readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router) || this.viewOnly()); + readonly canUpload = computed(() => !this.hasViewOnly() && this.supportUpload()); + readonly canShowMenu = computed( + () => Object.keys(this.allowedMenuActions()).length > 0 && !this.selectedFiles().length + ); readonly nodes = computed(() => { const currentFolder = this.currentFolder(); const files = this.files(); - const hasParent = this.foldersStack.length > 0; - if (hasParent) { - return [ - { - ...currentFolder, - previousFolder: hasParent, - }, - ...files, - ] as FileModel[]; - } else { - return [...files]; - } + + const values = this.foldersStack().length + ? ([{ ...currentFolder, previousFolder: true }, ...files] as FileModel[]) + : files; + + return FileTreeMapper.toTreeNodes(values); }); - // [NS] Temporary fix - readonly selectedNodes = computed(() => this.selectedFiles() as FileTreeNode[]); + readonly selectedNodes = computed(() => FileTreeMapper.toTreeNodes(this.selectedFiles())); constructor() { effect(() => { const storageChanged = this.storage(); if (storageChanged) { - this.foldersStack = []; - this.updateFoldersStack.emit(this.foldersStack); + this.foldersStack.set([]); } }); @@ -176,83 +135,29 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { }); } - ngAfterViewInit(): void { - if (!this.viewOnly()) { - this.dropZoneContainerRef()?.nativeElement?.addEventListener('dragenter', this.dragEnterHandler); - } - } - - ngOnDestroy(): void { - if (this.dropZoneContainerRef()?.nativeElement) { - this.dropZoneContainerRef()!.nativeElement.removeEventListener('dragenter', this.dragEnterHandler); - } - } - - private dragEnterHandler = (event: DragEvent) => { - if (event.dataTransfer?.types?.includes('Files') && !this.viewOnly()) { - this.isDragOver.set(true); - } - }; - - onDragOver(event: DragEvent) { - if (this.viewOnly()) { + onDropFiles(fileArray: File[]): void { + if (!fileArray.length) { return; } - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer!.dropEffect = 'copy'; - this.isDragOver.set(true); - } - onDragLeave(event: Event) { - if (this.viewOnly()) { - return; - } - event.preventDefault(); - event.stopPropagation(); - this.isDragOver.set(false); - } - - onDrop(event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - this.isDragOver.set(false); - - if (this.viewOnly()) { - return; - } + const isMultiple = fileArray.length > 1; - const files = event.dataTransfer?.files; - - if (files && files.length > 0) { - const fileArray = Array.from(files); - const isMultiple = files.length > 1; - - this.customConfirmationService.confirmAccept({ - headerKey: isMultiple ? 'files.dialogs.uploadFiles.title' : 'files.dialogs.uploadFile.title', - messageParams: isMultiple ? { count: files.length } : { name: files[0].name }, - messageKey: isMultiple ? 'files.dialogs.uploadFiles.message' : 'files.dialogs.uploadFile.message', - acceptLabelKey: 'common.buttons.upload', - onConfirm: () => this.uploadFilesConfirmed.emit(fileArray), - }); - } + this.customConfirmationService.confirmAccept({ + headerKey: isMultiple ? 'files.dialogs.uploadFiles.title' : 'files.dialogs.uploadFile.title', + messageParams: isMultiple ? { count: fileArray.length } : { name: fileArray[0].name }, + messageKey: isMultiple ? 'files.dialogs.uploadFiles.message' : 'files.dialogs.uploadFile.message', + acceptLabelKey: 'common.buttons.upload', + onConfirm: () => this.uploadFilesConfirmed.emit(fileArray), + }); } - openEntry(event: Event, file: FileModel | FileFolderModel) { - event.stopPropagation(); + openEntry(file: FileModel | FileFolderModel) { if (file.kind === FileKind.File) { - if (file.guid) { - this.entryFileClicked.emit(file); - } else { - this.filesService.getFileGuid(file.id).subscribe((file) => { - this.entryFileClicked.emit(file); - }); - } + this.entryFileClicked.emit(file); } else { const current = this.currentFolder(); if (current) { - this.foldersStack.push(current); - this.updateFoldersStack.emit(this.foldersStack); + this.foldersStack.update((stack) => [...stack, current]); } const folder = FilesMapper.mapFileToFolder(file as FileModel); this.setCurrentFolder.emit(folder); @@ -261,11 +166,14 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } openParentFolder() { - const previous = this.foldersStack.pop(); - this.updateFoldersStack.emit(this.foldersStack); + const stack = this.foldersStack(); + const previous = stack[stack.length - 1]; + this.foldersStack.set(stack.slice(0, -1)); + if (previous) { this.setCurrentFolder.emit(previous); } + this.clearSelection.emit(); } @@ -298,47 +206,15 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } downloadFileOrFolder(file: FileModel) { - const resourceType = this.resourceMetadata()?.type ?? 'nodes'; this.dataciteService - .logFileDownload(this.resourceId(), resourceType) + .logFileDownload(this.resourceId(), this.resourceType()) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); + if (file.kind === FileKind.File) { this.downloadFile(file.links.download); } else { - const folder = FilesMapper.mapFileToFolder(file as FileModel); - this.downloadFolder(folder.links.download); - } - } - - private handleShareAction(file: FileModel, shareType?: string): void { - const emailLink = `mailto:?subject=${file.name}&body=${file.links.html}`; - const twitterLink = `https://twitter.com/intent/tweet?url=${file.links.html}&text=${file.name}&via=OSFramework`; - const facebookLink = `https://www.facebook.com/dialog/share?app_id=${this.environment.facebookAppId}&display=popup&href=${file.links.html}&redirect_uri=${file.links.html}`; - - switch (shareType) { - case 'email': - this.openLink(emailLink); - break; - case 'twitter': - this.openLinkNewTab(twitterLink); - break; - case 'facebook': - this.openLinkNewTab(facebookLink); - break; - } - } - - private handleEmbedAction(file: FileModel, embedType?: string): void { - let embedHtml = ''; - if (embedType === 'dynamic') { - embedHtml = embedDynamicJs.replace('ENCODED_URL', file.links.render); - } else if (embedType === 'static') { - embedHtml = embedStaticHtml.replace('ENCODED_URL', file.links.render); - } - - if (embedHtml) { - this.copyToClipboard(embedHtml); + this.downloadFolder(file.links.upload); } } @@ -349,57 +225,37 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { messageKey: file.kind === FileKind.Folder ? 'files.dialogs.deleteFolder.message' : 'files.dialogs.deleteFile.message', acceptLabelKey: 'common.buttons.remove', - onConfirm: () => this.confirmDeleteEntry(file), + onConfirm: () => this.deleteEntryAction.emit(file), }); } - confirmDeleteEntry(file: FileModel): void { - this.deleteEntryAction.emit(file); - } - confirmRename(file: FileModel): void { this.customDialogService .open(RenameFileDialogComponent, { header: 'files.dialogs.renameFile.title', width: '448px', - data: { - currentName: file.name, - }, + data: { currentName: file.name }, }) - .onClose.subscribe((newName: string) => { - if (newName) { - this.renameEntry(newName, file); + .onClose.pipe( + takeUntilDestroyed(this.destroyRef), + filter((newName: string) => !!newName) + ) + .subscribe((newName) => { + if (newName.trim() && file.links.upload) { + const link = file.links.upload; + this.renameEntryAction.emit({ newName, link }); } }); } - renameEntry(newName: string, file: FileModel): void { - if (newName.trim() && file.links.upload) { - const link = file.links.upload; - this.renameEntryAction.emit({ newName, link }); - } - } - downloadFile(link: string): void { - if (isPlatformBrowser(this.platformId)) { + if (this.isBrowser) { window.open(link)?.focus(); } } - openLink(link: string): void { - if (isPlatformBrowser(this.platformId)) { - window.location.href = link; - } - } - - openLinkNewTab(link: string): void { - if (isPlatformBrowser(this.platformId)) { - window.open(link, '_blank', 'noopener,noreferrer'); - } - } - downloadFolder(downloadLink: string): void { - if (isPlatformBrowser(this.platformId) && downloadLink) { + if (downloadLink) { const link = this.filesService.getFolderDownloadLink(downloadLink); window.open(link, '_blank')?.focus(); } @@ -416,32 +272,11 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { resourceId: this.resourceId(), action: action, storageProvider: this.storage()?.folder.provider, - foldersStack: structuredClone(this.foldersStack), + foldersStack: structuredClone(this.foldersStack()), initialFolder: structuredClone(this.currentFolder()), }, }) - .onClose.subscribe(() => { - this.resetFilesProvider.emit(); - }); - } - - copyToClipboard(embedHtml: string): void { - this.clipboard.copy(embedHtml); - this.toastService.showSuccess('files.detail.toast.copiedToClipboard'); - } - - private loadNextPage(): void { - const total = this.totalCount(); - const loaded = this.files().length; - const nextPage = Math.floor(loaded / this.itemsPerPage) + 1; - - if (!this.isLoadingMore() && loaded < total) { - this.isLoadingMore.set(true); - this.loadFiles.emit({ - link: this.currentFolder()?.links.filesLink ?? '', - page: nextPage, - }); - } + .onClose.subscribe(() => this.resetFilesProvider.emit()); } onLazyLoad(event: TreeLazyLoadEvent) { @@ -453,7 +288,12 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { onNodeSelect(event: TreeNodeSelectEvent) { const files = this.files(); - const selectedNode = event.node as FileModel; + const selectedNode = event.node.data as FileModel; + + if (!selectedNode) { + return; + } + if ((event.originalEvent as PointerEvent).shiftKey && this.lastSelectedFile) { const lastIndex = files.indexOf(this.lastSelectedFile); const currentIndex = files.indexOf(selectedNode); @@ -468,25 +308,42 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { this.selectFile.emit(file); } } + this.selectFile.emit(selectedNode); this.lastSelectedFile = selectedNode; } onNodeDrop(event: TreeNodeDropEvent) { - const dropFile = event.dropNode as FileModel; - if (dropFile.kind !== FileKind.Folder) { + const dropFile = event.dropNode?.data as FileModel; + + if (dropFile?.kind !== FileKind.Folder) { + return; + } + + const selectedFiles = this.selectedFiles(); + const dragFile = event.dragNode?.data as FileModel; + + if (!dragFile) { return; } - const files = this.selectedFiles(); - const dragFile = event.dragNode as FileModel; - if (!files.includes(dragFile)) { + + const filesToMove = selectedFiles.includes(dragFile) ? selectedFiles : [...selectedFiles, dragFile]; + + if (!selectedFiles.includes(dragFile)) { this.selectFile.emit(dragFile); } - this.moveFilesTo(files, dropFile); + + this.moveFilesTo(filesToMove, dropFile); } onNodeUnselect(event: TreeNodeSelectEvent) { - this.unselectFile.emit(event.node as FileModel); + const unselectedNode = event.node.data as FileModel; + + if (!unselectedNode) { + return; + } + + this.unselectFile.emit(unselectedNode); } private moveFilesTo(files: FileModel[], destination: FileModel) { @@ -502,8 +359,35 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { storageProvider: this.storage()?.folder.provider, }, }) - .onClose.subscribe(() => { - this.resetFilesProvider.emit(); - }); + .onClose.subscribe(() => this.resetFilesProvider.emit()); + } + + private loadNextPage(): void { + const total = this.totalCount(); + const loaded = this.files().length; + const nextPage = Math.floor(loaded / this.itemsPerPage) + 1; + + if (!this.isLoadingMore() && loaded < total) { + this.isLoadingMore.set(true); + this.loadFiles.emit({ link: this.currentFolder()?.links.filesLink ?? '', page: nextPage }); + } + } + + private handleShareAction(file: FileModel, shareType?: string): void { + const shareAction = this.filesShareEmbedService.getShareLink(file, shareType); + if (!shareAction || !this.isBrowser) { + return; + } + + if (shareAction.target === '_self') { + window.location.href = shareAction.link; + return; + } + + window.open(shareAction.link, shareAction.target, 'noopener,noreferrer'); + } + + private handleEmbedAction(file: FileModel, embedType?: string): void { + this.filesShareEmbedService.copyEmbedToClipboard(file.links.render, embedType); } } diff --git a/src/app/shared/components/socials-share-button/socials-share-button.component.ts b/src/app/shared/components/socials-share-button/socials-share-button.component.ts index 23ed332eb..f51128d45 100644 --- a/src/app/shared/components/socials-share-button/socials-share-button.component.ts +++ b/src/app/shared/components/socials-share-button/socials-share-button.component.ts @@ -33,11 +33,7 @@ export class SocialsShareButtonComponent { ? this.socialShareService.createPreprintUrl(this.resourceId(), this.resourceProvider()) : this.socialShareService.createGuidUrl(this.resourceId()); - const shareableContent: SocialShareContentModel = { - id: this.resourceId(), - title: this.resourceTitle(), - url: resourceUrl, - }; + const shareableContent: SocialShareContentModel = { title: this.resourceTitle(), url: resourceUrl }; return this.socialShareService.generateSocialActionItems(shareableContent); }); diff --git a/src/app/shared/components/status-badge/status-badge.component.html b/src/app/shared/components/status-badge/status-badge.component.html index 0f2b18722..261413749 100644 --- a/src/app/shared/components/status-badge/status-badge.component.html +++ b/src/app/shared/components/status-badge/status-badge.component.html @@ -1,3 +1,3 @@ -@if (label) { - +@if (label()) { + } diff --git a/src/app/shared/components/status-badge/status-badge.component.ts b/src/app/shared/components/status-badge/status-badge.component.ts index 5610e175b..1fa211862 100644 --- a/src/app/shared/components/status-badge/status-badge.component.ts +++ b/src/app/shared/components/status-badge/status-badge.component.ts @@ -2,11 +2,10 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { RegistryStatusMap } from '@osf/shared/constants/registration-statuses'; import { RegistryStatus } from '@osf/shared/enums/registry-status.enum'; -import { TagSeverityType } from '@osf/shared/models/severity.type'; @Component({ selector: 'osf-status-badge', @@ -18,11 +17,6 @@ import { TagSeverityType } from '@osf/shared/models/severity.type'; export class StatusBadgeComponent { status = input.required(); - get label(): string { - return RegistryStatusMap[this.status()]?.label ?? 'Unknown'; - } - - get severity(): TagSeverityType | null { - return RegistryStatusMap[this.status()]?.severity ?? null; - } + label = computed(() => RegistryStatusMap[this.status()]?.label ?? 'resourceCard.type.null'); + severity = computed(() => RegistryStatusMap[this.status()]?.severity ?? null); } diff --git a/src/app/shared/components/subjects/subjects.component.ts b/src/app/shared/components/subjects/subjects.component.ts index 966742a8b..f201d0fbe 100644 --- a/src/app/shared/components/subjects/subjects.component.ts +++ b/src/app/shared/components/subjects/subjects.component.ts @@ -11,7 +11,8 @@ import { Tree, TreeModule } from 'primeng/tree'; import { debounceTime, distinctUntilChanged } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; import { SubjectModel } from '@osf/shared/models/subject/subject.model'; @@ -27,11 +28,14 @@ import { SearchInputComponent } from '../search-input/search-input.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SubjectsComponent { + readonly destroyRef = inject(DestroyRef); + subjects = select(SubjectsSelectors.getSubjects); subjectsLoading = select(SubjectsSelectors.getSubjectsLoading); searchedSubjects = select(SubjectsSelectors.getSearchedSubjects); - areSubjectsUpdating = input(false); isSearching = select(SubjectsSelectors.getSearchedSubjectsLoading); + + areSubjectsUpdating = input(false); selected = input([]); readonly = input(false); searchChanged = output(); @@ -51,9 +55,11 @@ export class SubjectsComponent { searchControl = new FormControl(''); constructor() { - this.searchControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => { - this.searchChanged.emit(value ?? ''); - }); + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + this.searchChanged.emit(value ?? ''); + }); } loadNode(event: TreeNode) { diff --git a/src/app/features/files/constants/embed-content.constants.ts b/src/app/shared/constants/file-embed.constants.ts similarity index 100% rename from src/app/features/files/constants/embed-content.constants.ts rename to src/app/shared/constants/file-embed.constants.ts diff --git a/src/app/shared/constants/social-share.config.ts b/src/app/shared/constants/social-share.config.ts index 88f7d17e2..7a0e2bfaf 100644 --- a/src/app/shared/constants/social-share.config.ts +++ b/src/app/shared/constants/social-share.config.ts @@ -1,7 +1,8 @@ export const SOCIAL_SHARE_URLS = { email: 'mailto:', - twitter: { preview_url: 'https://twitter.com/intent/tweet', viaHandle: 'OsfFramework' }, + x: { preview_url: 'https://x.com/intent/tweet', viaHandle: 'OsfFramework' }, facebook: 'https://www.facebook.com/sharer/sharer.php', + facebookShare: 'https://www.facebook.com/dialog/share', linkedIn: 'https://www.linkedin.com/sharing/share-offsite', mastodon: 'https://mastodonshare.com', bluesky: 'https://bsky.app/intent/compose', diff --git a/src/app/shared/helpers/mfr-url.helper.ts b/src/app/shared/helpers/mfr-url.helper.ts new file mode 100644 index 000000000..a3fa1093b --- /dev/null +++ b/src/app/shared/helpers/mfr-url.helper.ts @@ -0,0 +1,19 @@ +export function getMfrUrlWithVersion( + mfrUrl: string | undefined, + version?: string, + viewOnlyParam?: string | null +): string | null { + 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 (viewOnlyParam) downloadUrlObj.searchParams.set('view_only', viewOnlyParam); + + mfrUrlObj.searchParams.set('url', downloadUrlObj.toString()); + + return mfrUrlObj.toString(); +} diff --git a/src/app/shared/helpers/storage-addon-options.helper.ts b/src/app/shared/helpers/storage-addon-options.helper.ts new file mode 100644 index 000000000..18ec482b5 --- /dev/null +++ b/src/app/shared/helpers/storage-addon-options.helper.ts @@ -0,0 +1,31 @@ +import { FileProvider } from '@osf/features/files/constants'; +import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model'; +import { FileFolderModel } from '@shared/models/files/file-folder.model'; +import { FileLabelModel } from '@shared/models/files/file-label.model'; + +export function getConfiguredStorageAddonDisplayName( + addons: ConfiguredAddonModel[], + provider: string, + osfStorageLabel: string +): string { + if (provider === FileProvider.OsfStorage) { + return osfStorageLabel; + } + + return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; +} + +export function mapRootFoldersToStorageLabels( + rootFolders: FileFolderModel[] | null | undefined, + addons: ConfiguredAddonModel[] | null | undefined, + osfStorageLabel: string +): FileLabelModel[] { + if (!rootFolders || !addons) { + return []; + } + + return rootFolders.map((folder) => ({ + label: getConfiguredStorageAddonDisplayName(addons, folder.provider, osfStorageLabel), + folder, + })); +} diff --git a/src/app/shared/mappers/files/file-tree.mapper.ts b/src/app/shared/mappers/files/file-tree.mapper.ts new file mode 100644 index 000000000..7f84211fe --- /dev/null +++ b/src/app/shared/mappers/files/file-tree.mapper.ts @@ -0,0 +1,16 @@ +import { FileModel } from '@shared/models/files/file.model'; +import { FileTreeNode } from '@shared/models/files/file-tree-node.model'; + +export class FileTreeMapper { + static toTreeNode(file: FileModel): FileTreeNode { + return { + key: file.id, + label: file.name, + data: file, + }; + } + + static toTreeNodes(files: FileModel[]): FileTreeNode[] { + return files.map((file) => this.toTreeNode(file)); + } +} diff --git a/src/app/shared/models/files/file-link.model.ts b/src/app/shared/models/files/file-link.model.ts new file mode 100644 index 000000000..abf1f8d49 --- /dev/null +++ b/src/app/shared/models/files/file-link.model.ts @@ -0,0 +1,4 @@ +export interface FileLinkModel { + file: File; + link: string; +} diff --git a/src/app/shared/models/files/file-page-link.model.ts b/src/app/shared/models/files/file-page-link.model.ts new file mode 100644 index 000000000..a5b6ed0f5 --- /dev/null +++ b/src/app/shared/models/files/file-page-link.model.ts @@ -0,0 +1,4 @@ +export interface FilePageLinkModel { + link: string; + page: number; +} diff --git a/src/app/shared/models/files/file-share-link.model.ts b/src/app/shared/models/files/file-share-link.model.ts new file mode 100644 index 000000000..60a018ea6 --- /dev/null +++ b/src/app/shared/models/files/file-share-link.model.ts @@ -0,0 +1,4 @@ +export interface FileShareLink { + link: string; + target: '_self' | '_blank'; +} diff --git a/src/app/shared/models/files/file-tree-node.model.ts b/src/app/shared/models/files/file-tree-node.model.ts new file mode 100644 index 000000000..7f35896a8 --- /dev/null +++ b/src/app/shared/models/files/file-tree-node.model.ts @@ -0,0 +1,5 @@ +import { TreeNode } from 'primeng/api'; + +import { FileModel } from './file.model'; + +export type FileTreeNode = TreeNode; diff --git a/src/app/shared/models/files/renamed-file-link.model.ts b/src/app/shared/models/files/renamed-file-link.model.ts new file mode 100644 index 000000000..81876ff0e --- /dev/null +++ b/src/app/shared/models/files/renamed-file-link.model.ts @@ -0,0 +1,4 @@ +export interface RenamedFileLinkModel { + newName: string; + link: string; +} diff --git a/src/app/shared/models/socials/social-share-content.model.ts b/src/app/shared/models/socials/social-share-content.model.ts index fc5b94031..ec393ac08 100644 --- a/src/app/shared/models/socials/social-share-content.model.ts +++ b/src/app/shared/models/socials/social-share-content.model.ts @@ -1,5 +1,4 @@ export interface SocialShareContentModel { - id: string; title: string; url: string; } diff --git a/src/app/shared/services/files-share-embed.service.ts b/src/app/shared/services/files-share-embed.service.ts new file mode 100644 index 000000000..399f81658 --- /dev/null +++ b/src/app/shared/services/files-share-embed.service.ts @@ -0,0 +1,77 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { inject, Injectable } from '@angular/core'; + +import { embedDynamicJs, embedStaticHtml } from '@shared/constants/file-embed.constants'; + +import { FileModel } from '../models/files/file.model'; +import { FileShareLink } from '../models/files/file-share-link.model'; + +import { SocialShareService } from './social-share.service'; +import { ToastService } from './toast.service'; + +@Injectable({ + providedIn: 'root', +}) +export class FilesShareEmbedService { + private readonly clipboard = inject(Clipboard); + private readonly socialShareService = inject(SocialShareService); + private readonly toastService = inject(ToastService); + + private readonly EMBED_PLACEHOLDER = 'ENCODED_URL'; + + getShareLink(file: FileModel, shareType?: string): FileShareLink | null { + const name = file.name ?? ''; + const url = file.links?.html ?? ''; + + if (!url) { + return null; + } + + switch (shareType) { + case 'email': + return { + link: this.socialShareService.getEmailLink(name, url), + target: '_self', + }; + case 'twitter': + return { + link: this.socialShareService.getXLink(name, url), + target: '_blank', + }; + case 'facebook': + return { + link: this.socialShareService.getFacebookLink(url), + target: '_blank', + }; + default: + return null; + } + } + + getEmbedHtml(url: string, embedType?: string): string { + switch (embedType) { + case 'dynamic': + return embedDynamicJs.replace(this.EMBED_PLACEHOLDER, url); + case 'static': + return embedStaticHtml.replace(this.EMBED_PLACEHOLDER, url); + default: + return ''; + } + } + + copyEmbedToClipboard(url: string, embedType?: string): boolean { + const embedHtml = this.getEmbedHtml(url, embedType); + + if (!embedHtml) { + return false; + } + + const copied = this.clipboard.copy(embedHtml); + + if (copied) { + this.toastService.showSuccess('files.detail.toast.copiedToClipboard'); + } + + return copied; + } +} diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index ad78cb023..95a6993eb 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -6,19 +6,20 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { MapFileCustomMetadata, MapFileRevision } from '@osf/features/files/mappers'; +import { OsfFileCustomMetadata } from '@osf/features/files/models/file-custom-metadata.model'; +import { OsfFileRevision } from '@osf/features/files/models/file-revisions.model'; +import { GetCustomMetadataResponse } from '@osf/features/files/models/get-custom-metadata-response.model'; import { FileCustomMetadata, - GetCustomMetadataResponse, GetFileMetadataResponse, - GetFileRevisionsResponse, - GetShortInfoResponse, - OsfFileCustomMetadata, - OsfFileRevision, - PatchFileMetadata, -} from '@osf/features/files/models'; +} from '@osf/features/files/models/get-file-metadata-response.model'; +import { GetFileRevisionsResponse } from '@osf/features/files/models/get-file-revisions-response.model'; +import { GetShortInfoResponse } from '@osf/features/files/models/get-short-info-response.model'; +import { PatchFileMetadata } from '@osf/features/files/models/patch-file-metadata.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { FileKind } from '../enums/file-kind.enum'; +import { ResourceType } from '../enums/resource-type.enum'; import { AddonMapper } from '../mappers/addon.mapper'; import { ContributorsMapper } from '../mappers/contributors'; import { FilesMapper } from '../mappers/files/files.mapper'; @@ -60,7 +61,13 @@ export class FilesService { return this.environment.addonsApiUrl; } - filesFields = 'name,guid,kind,extra,size,path,materialized_path,date_modified,parent_folder,files'; + private readonly filesFields = 'name,guid,kind,extra,size,path,materialized_path,date_modified,parent_folder,files'; + + private readonly resourcePathMap: Record = { + [ResourceType.Project]: 'nodes', + [ResourceType.Registration]: 'registrations', + [ResourceType.Preprint]: 'preprints', + }; getFiles( filesLink: string, @@ -86,10 +93,17 @@ export class FilesService { .pipe(map((response) => ({ files: FilesMapper.getFileFolders(response.data), meta: response.meta }))); } + getRootFolders( + resourceId: string, + resourceType: ResourceType + ): Observable<{ files: FileFolderModel[]; meta?: MetaJsonApi }> { + const resourcePath = this.resourcePathMap[resourceType]; + return this.getFolders(`${this.apiUrl}/${resourcePath}/${resourceId}/files/`); + } + getFilesWithoutFiltering(filesLink: string, page = 1): Observable> { - const params: Record = { - page: page.toString(), - }; + const params: Record = { page: page.toString() }; + return this.jsonApiService.get(filesLink, params).pipe( map((response) => ({ data: FilesMapper.getFiles(response.data), @@ -169,9 +183,7 @@ export class FilesService { } getFileGuid(id: string): Observable { - const params = { - create_guid: 'true', - }; + const params = { create_guid: 'true' }; return this.jsonApiService .get(`${this.apiUrl}/files/${id}/`, params) @@ -278,9 +290,7 @@ export class FilesService { } getResourceReferences(resourceUri: string): Observable { - const params = { - 'filter[resource_uri]': resourceUri, - }; + const params = { 'filter[resource_uri]': resourceUri }; return this.jsonApiService .get< @@ -289,7 +299,9 @@ export class FilesService { .pipe(map((response) => response.data?.[0]?.links?.self ?? '')); } - getConfiguredStorageAddons(resourceUri: string): Observable { + getConfiguredStorageAddons(resourceId: string): Observable { + const resourceUri = `${this.environment.webUrl}/${resourceId}`; + return this.getResourceReferences(resourceUri).pipe( switchMap((referenceUrl: string) => { if (!referenceUrl) return of([]); diff --git a/src/app/shared/services/meta-tags-builder.service.ts b/src/app/shared/services/meta-tags-builder.service.ts index 28dd524f4..31e8f8db7 100644 --- a/src/app/shared/services/meta-tags-builder.service.ts +++ b/src/app/shared/services/meta-tags-builder.service.ts @@ -4,7 +4,7 @@ import { formatDate } from '@angular/common'; import { inject, Injectable, LOCALE_ID } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { OsfFileCustomMetadata } from '@osf/features/files/models'; +import { OsfFileCustomMetadata } from '@osf/features/files/models/file-custom-metadata.model'; import { PreprintModel } from '@osf/features/preprints/models'; import { ProjectOverviewModel } from '@osf/features/project/overview/models'; import { RegistrationOverviewModel } from '@osf/features/registry/models'; diff --git a/src/app/shared/services/social-share.service.ts b/src/app/shared/services/social-share.service.ts index 7b821619a..d8c9832f2 100644 --- a/src/app/shared/services/social-share.service.ts +++ b/src/app/shared/services/social-share.service.ts @@ -18,10 +18,29 @@ export class SocialShareService { return this.environment.webUrl; } + getEmailLink(title: string, url: string): string { + return this.generateEmailLink({ title, url }); + } + + getXLink(title: string, url: string): string { + return this.generateXLink({ title, url }); + } + + getFacebookLink(url: string): string { + return this.generateFacebookLink({ title: '', url }); + } + + getFacebookCustomLink(url: string): string { + const encodedUrl = encodeURIComponent(url); + const appId = this.environment.facebookAppId; + + return `${SOCIAL_SHARE_URLS.facebookShare}?app_id=${appId}&display=popup&href=${encodedUrl}&redirect_uri=${encodedUrl}`; + } + generateAllSharingLinks(content: SocialShareContentModel): SocialShareLinksModel { return { email: this.generateEmailLink(content), - twitter: this.generateTwitterLink(content), + twitter: this.generateXLink(content), facebook: this.generateFacebookLink(content), linkedIn: this.generateLinkedInLink(content), mastodon: this.generateMastodonLink(content), @@ -58,11 +77,11 @@ export class SocialShareService { return `${SOCIAL_SHARE_URLS.email}?subject=${subject}&body=${body}`; } - private generateTwitterLink(content: SocialShareContentModel): string { + private generateXLink(content: SocialShareContentModel): string { const url = encodeURIComponent(content.url); const text = encodeURIComponent(content.title); - return `${SOCIAL_SHARE_URLS.twitter.preview_url}?url=${url}&text=${text}&via=${SOCIAL_SHARE_URLS.twitter.viaHandle}`; + return `${SOCIAL_SHARE_URLS.x.preview_url}?url=${url}&text=${text}&via=${SOCIAL_SHARE_URLS.x.viaHandle}`; } private generateFacebookLink(content: SocialShareContentModel): string { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 6ad050f6c..8664c4df3 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1120,6 +1120,7 @@ "deleteProject": "Delete Project", "descriptions": { "file_updated": { + "instant": "You'll be notified immediately when files are updated.", "daily": "You'll receive a daily summary of file updates.", "instant": "You'll be notified immediately when files are updated.", "none": "You won't receive file update notifications." diff --git a/src/styles/overrides/tree.scss b/src/styles/overrides/tree.scss index e30a4f1e8..86f6f2f96 100644 --- a/src/styles/overrides/tree.scss +++ b/src/styles/overrides/tree.scss @@ -2,6 +2,13 @@ .p-tree { padding: 0; + .files-table-row { + .p-button { + --p-button-label-font-weight: 400; + --p-button-link-color: var(--dark-blue-1); + } + } + .p-tree-node-toggle-button { display: none; } @@ -18,7 +25,7 @@ .p-tree-node-dragover { .files-table-row { - background: var(--bg-blue-3); + background-color: var(--bg-blue-3); } } @@ -47,13 +54,17 @@ } .p-tree-empty-message { - display: none; + height: 100%; } .p-tree-node-selected { .files-table-row { color: var(--white); - background: var(--pr-blue-1); + background-color: var(--pr-blue-1); + + .p-button { + --p-button-link-color: var(--white); + } .blue-icon { color: var(--white); @@ -61,18 +72,4 @@ } } } - - .empty-state-container { - position: absolute; - inset: 0; - top: 2.75rem; - display: flex; - justify-content: center; - align-items: center; - - .drop-text { - text-align: center; - margin-bottom: 2.75rem; - } - } }