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) {
}
- @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()) {
-
- }
-
- }
-
-
+
+
+
+ 0"
+ [showMenu]="canShowMenu()"
+ [allowedMenuActions]="allowedMenuActions()"
+ (openParentFolder)="openParentFolder()"
+ (openEntry)="openEntry($event)"
+ (menuAction)="onFileMenuAction($event, file.data)"
+ >
+
- @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;
- }
- }
}