-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
+
+
+
+
-
+
+
+
+
{{ fileName() }}
+
+
+
+
{{ progress() }} %
+
+
-
-
-
-
-
{{ fileName() }}
-
-
-
-
{{ progress() }} %
-
-
+
-
-
-
-
+}
diff --git a/src/app/features/project/files/pages/project-files/project-files.component.scss b/src/app/features/project/files/pages/project-files/project-files.component.scss
index 61085d375..d513bbd19 100644
--- a/src/app/features/project/files/pages/project-files/project-files.component.scss
+++ b/src/app/features/project/files/pages/project-files/project-files.component.scss
@@ -16,5 +16,9 @@
}
.upload-dialog {
- width: mix.rem(48px);
+ width: mix.rem(128px);
+}
+
+.provider-name {
+ text-transform: capitalize;
}
diff --git a/src/app/features/project/files/pages/project-files/project-files.component.ts b/src/app/features/project/files/pages/project-files/project-files.component.ts
index 09f6f92cd..2ef472170 100644
--- a/src/app/features/project/files/pages/project-files/project-files.component.ts
+++ b/src/app/features/project/files/pages/project-files/project-files.component.ts
@@ -10,10 +10,20 @@ import { FloatLabel } from 'primeng/floatlabel';
import { Select } from 'primeng/select';
import { TableModule } from 'primeng/table';
-import { debounceTime, filter, finalize, Observable, skip, take } from 'rxjs';
+import { debounceTime, EMPTY, filter, finalize, Observable, skip, take } from 'rxjs';
import { HttpEventType } from '@angular/common/http';
-import { ChangeDetectionStrategy, Component, DestroyRef, HostBinding, inject, signal } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ DestroyRef,
+ effect,
+ HostBinding,
+ inject,
+ model,
+ signal,
+} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
@@ -23,8 +33,9 @@ import { FilesTreeActions } from '@osf/features/project/files/models';
import {
CreateFolder,
DeleteEntry,
+ GetConfiguredStorageAddons,
GetFiles,
- GetRootFolderFiles,
+ GetRootFolders,
ProjectFilesSelectors,
RenameEntry,
SetCurrentFolder,
@@ -34,6 +45,7 @@ import {
SetSort,
} from '@osf/features/project/files/store';
import { approveFile } from '@osf/features/project/files/utils';
+import { GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store';
import { ALL_SORT_OPTIONS } from '@osf/shared/constants';
import {
FilesTreeComponent,
@@ -42,7 +54,7 @@ import {
SearchInputComponent,
SubHeaderComponent,
} from '@shared/components';
-import { OsfFile } from '@shared/models';
+import { ConfiguredStorageAddon, OsfFile } from '@shared/models';
import { FilesService } from '@shared/services';
@Component({
@@ -80,13 +92,15 @@ export class ProjectFilesComponent {
createFolder: CreateFolder,
deleteEntry: DeleteEntry,
getFiles: GetFiles,
- getRootFolderFiles: GetRootFolderFiles,
renameEntry: RenameEntry,
setCurrentFolder: SetCurrentFolder,
setFilesIsLoading: SetFilesIsLoading,
setMoveFileCurrentFolder: SetMoveFileCurrentFolder,
setSearch: SetSearch,
setSort: SetSort,
+ getProject: GetProjectById,
+ getRootFolders: GetRootFolders,
+ getConfiguredStorageAddons: GetConfiguredStorageAddons,
});
protected readonly files = select(ProjectFilesSelectors.getFiles);
@@ -94,12 +108,32 @@ export class ProjectFilesComponent {
protected readonly currentFolder = select(ProjectFilesSelectors.getCurrentFolder);
protected readonly provider = select(ProjectFilesSelectors.getProvider);
+ protected readonly project = select(ProjectOverviewSelectors.getProject);
protected readonly projectId = signal
('');
+ private readonly rootFolders = select(ProjectFilesSelectors.getRootFolders);
+ protected isRootFoldersLoading = select(ProjectFilesSelectors.isRootFoldersLoading);
+ private readonly configuredStorageAddons = select(ProjectFilesSelectors.getConfiguredStorageAddons);
+ protected isConfiguredStorageAddonsLoading = select(ProjectFilesSelectors.isConfiguredStorageAddonsLoading);
+ protected currentRootFolder = model<{ label: string; folder: OsfFile } | null>(null);
protected readonly progress = signal(0);
protected readonly fileName = signal('');
+ protected readonly dataLoaded = signal(false);
protected readonly searchControl = new FormControl('');
protected readonly sortControl = new FormControl(ALL_SORT_OPTIONS[0].value);
+ protected 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 [];
+ });
+
fileIsUploading = signal(false);
isFolderOpening = signal(false);
@@ -107,11 +141,8 @@ export class ProjectFilesComponent {
protected readonly filesTreeActions: FilesTreeActions = {
setCurrentFolder: (folder) => this.actions.setCurrentFolder(folder),
- setSearch: (search) => this.actions.setSearch(search),
- setSort: (sort) => this.actions.setSort(sort),
setFilesIsLoading: (isLoading) => this.actions.setFilesIsLoading(isLoading),
getFiles: (filesLink) => this.actions.getFiles(filesLink),
- getRootFolderFiles: (projectId) => this.actions.getRootFolderFiles(projectId),
deleteEntry: (projectId, link) => this.actions.deleteEntry(projectId, link),
renameEntry: (projectId, link, newName) => this.actions.renameEntry(projectId, link, newName),
setMoveFileCurrentFolder: (folder) => this.actions.setMoveFileCurrentFolder(folder),
@@ -121,7 +152,55 @@ export class ProjectFilesComponent {
this.activeRoute.parent?.parent?.parent?.params.subscribe((params) => {
if (params['id']) {
this.projectId.set(params['id']);
- this.actions.getRootFolderFiles(params['id']);
+ if (!this.project()) {
+ this.filesTreeActions.setFilesIsLoading?.(true);
+ this.actions.getProject(params['id']);
+ }
+ }
+ });
+
+ effect(() => {
+ const project = this.project();
+
+ if (project) {
+ this.actions.getRootFolders(project.links.rootFolder);
+ this.actions.getConfiguredStorageAddons(project.links.iri);
+ }
+ });
+
+ effect(() => {
+ const rootFolders = this.rootFolders();
+
+ if (rootFolders) {
+ const osfRootFolder = rootFolders.find((folder) => folder.provider === 'osfstorage');
+ if (osfRootFolder) {
+ this.currentRootFolder.set({
+ label: 'Osf Storage',
+ folder: osfRootFolder,
+ });
+ }
+ }
+ });
+
+ effect(() => {
+ const currentRootFolder = this.currentRootFolder();
+
+ if (currentRootFolder) {
+ this.actions.setCurrentFolder(currentRootFolder.folder);
+ }
+ });
+
+ effect(() => {
+ const currentFolder = this.currentFolder();
+
+ if (currentFolder) {
+ this.actions.getFiles(currentFolder.relationships.filesLink);
+ }
+ });
+
+ effect(() => {
+ if (!this.isFilesLoading() && !this.isConfiguredStorageAddonsLoading() && !this.isRootFoldersLoading()) {
+ this.dataLoaded.set(true);
}
});
@@ -142,21 +221,23 @@ export class ProjectFilesComponent {
});
}
- onFileSelected(event: Event): void {
- const input = event.target as HTMLInputElement;
- const file = input.files?.[0];
- if (!file) return;
+ uploadFile(file: File): void {
+ const currentFolder = this.currentFolder();
+ const uploadLink = currentFolder?.links.upload;
+
+ if (!uploadLink) return;
this.fileName.set(file.name);
this.fileIsUploading.set(true);
+
this.filesService
- .uploadFile(file, this.projectId(), this.provider(), this.currentFolder())
+ .uploadFile(file, uploadLink)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
+ this.fileIsUploading.set(false);
this.fileName.set('');
- input.value = '';
- this.updateFilesList().subscribe(() => this.fileIsUploading.set(false));
+ this.updateFilesList();
})
)
.subscribe((event) => {
@@ -173,7 +254,20 @@ export class ProjectFilesComponent {
});
}
+ onFileSelected(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const file = input.files?.[0];
+ if (!file) return;
+
+ this.uploadFile(file);
+ }
+
createFolder(): void {
+ const currentFolder = this.currentFolder();
+ const newFolderLink = currentFolder?.links.newFolder;
+
+ if (!newFolderLink) return;
+
this.dialogService
.open(CreateFolderDialogComponent, {
width: '448px',
@@ -186,11 +280,7 @@ export class ProjectFilesComponent {
.onClose.pipe(filter((folderName: string) => !!folderName))
.subscribe((folderName) => {
this.actions
- .createFolder(
- this.projectId(),
- folderName,
- this.currentFolder()?.relationships?.parentFolderId ? this.currentFolder()!.id : ''
- )
+ .createFolder(newFolderLink, folderName)
.pipe(
take(1),
finalize(() => {
@@ -205,13 +295,14 @@ export class ProjectFilesComponent {
const projectId = this.projectId();
const folderId = this.currentFolder()?.id ?? '';
const isRootFolder = !this.currentFolder()?.relationships?.parentFolderLink;
+ const provider = this.currentRootFolder()?.folder?.provider ?? 'osfstorage';
if (projectId && folderId) {
if (isRootFolder) {
- const link = this.filesService.getFolderDownloadLink(projectId, this.provider(), '', true);
+ const link = this.filesService.getFolderDownloadLink(projectId, provider, '', true);
window.open(link, '_blank')?.focus();
} else {
- const link = this.filesService.getFolderDownloadLink(projectId, this.provider(), folderId, false);
+ const link = this.filesService.getFolderDownloadLink(projectId, provider, folderId, false);
window.open(link, '_blank')?.focus();
}
}
@@ -220,10 +311,11 @@ export class ProjectFilesComponent {
updateFilesList(): Observable {
const currentFolder = this.currentFolder();
if (currentFolder?.relationships.filesLink) {
- return this.actions.getFiles(currentFolder?.relationships.filesLink).pipe(takeUntilDestroyed(this.destroyRef));
- } else {
- return this.actions.getRootFolderFiles(this.projectId());
+ this.filesTreeActions.setFilesIsLoading?.(true);
+ return this.actions.getFiles(currentFolder?.relationships.filesLink).pipe(take(1));
}
+
+ return EMPTY;
}
folderIsOpening(value: boolean): void {
@@ -237,4 +329,12 @@ export class ProjectFilesComponent {
navigateToFile(file: OsfFile) {
this.router.navigate([file.guid], { relativeTo: this.activeRoute });
}
+
+ getAddonName(addons: ConfiguredStorageAddon[], provider: string): string {
+ if (provider === 'osfstorage') {
+ return 'Osf Storage';
+ } else {
+ return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? '';
+ }
+ }
}
diff --git a/src/app/features/project/files/store/project-files.actions.ts b/src/app/features/project/files/store/project-files.actions.ts
index db3cdb0de..10a1f5a0b 100644
--- a/src/app/features/project/files/store/project-files.actions.ts
+++ b/src/app/features/project/files/store/project-files.actions.ts
@@ -65,9 +65,8 @@ export class CreateFolder {
static readonly type = '[Project Files] Create folder';
constructor(
- public projectId: string,
- public folderName: string,
- public folderId: string
+ public newFolderLink: string,
+ public folderName: string
) {}
}
@@ -133,3 +132,15 @@ export class UpdateTags {
public fileGuid: string
) {}
}
+
+export class GetRootFolders {
+ static readonly type = '[Project Files] Get Folders';
+
+ constructor(public folderLink: string) {}
+}
+
+export class GetConfiguredStorageAddons {
+ static readonly type = '[Project Files] Get ConfiguredStorageAddons';
+
+ constructor(public resourceUri: string) {}
+}
diff --git a/src/app/features/project/files/store/project-files.model.ts b/src/app/features/project/files/store/project-files.model.ts
index 7071f7751..b97c74559 100644
--- a/src/app/features/project/files/store/project-files.model.ts
+++ b/src/app/features/project/files/store/project-files.model.ts
@@ -6,6 +6,7 @@ import {
OsfProjectMetadata,
} from '@osf/features/project/files/models';
import { OsfFile } from '@shared/models';
+import { ConfiguredStorageAddon } from '@shared/models/addons';
import { AsyncStateModel } from '@shared/models/store';
export interface ProjectFilesStateModel {
@@ -22,4 +23,6 @@ export interface ProjectFilesStateModel {
contributors: AsyncStateModel;
fileRevisions: AsyncStateModel;
tags: AsyncStateModel;
+ rootFolders: AsyncStateModel;
+ configuredStorageAddons: AsyncStateModel;
}
diff --git a/src/app/features/project/files/store/project-files.selectors.ts b/src/app/features/project/files/store/project-files.selectors.ts
index fb8fa6028..24c6fda03 100644
--- a/src/app/features/project/files/store/project-files.selectors.ts
+++ b/src/app/features/project/files/store/project-files.selectors.ts
@@ -7,6 +7,7 @@ import {
OsfProjectMetadata,
} from '@osf/features/project/files/models';
import { OsfFile } from '@shared/models';
+import { ConfiguredStorageAddon } from '@shared/models/addons';
import { ProjectFilesStateModel } from './project-files.model';
import { ProjectFilesState } from './project-files.state';
@@ -106,4 +107,24 @@ export class ProjectFilesSelectors {
static isFileTagsLoading(state: ProjectFilesStateModel): boolean {
return state.tags.isLoading;
}
+
+ @Selector([ProjectFilesState])
+ static getRootFolders(state: ProjectFilesStateModel): OsfFile[] | null {
+ return state.rootFolders.data;
+ }
+
+ @Selector([ProjectFilesState])
+ static isRootFoldersLoading(state: ProjectFilesStateModel): boolean {
+ return state.rootFolders.isLoading;
+ }
+
+ @Selector([ProjectFilesState])
+ static getConfiguredStorageAddons(state: ProjectFilesStateModel): ConfiguredStorageAddon[] | null {
+ return state.configuredStorageAddons.data;
+ }
+
+ @Selector([ProjectFilesState])
+ static isConfiguredStorageAddonsLoading(state: ProjectFilesStateModel): boolean {
+ return state.configuredStorageAddons.isLoading;
+ }
}
diff --git a/src/app/features/project/files/store/project-files.state.ts b/src/app/features/project/files/store/project-files.state.ts
index 8cfc6108c..a4587b1df 100644
--- a/src/app/features/project/files/store/project-files.state.ts
+++ b/src/app/features/project/files/store/project-files.state.ts
@@ -1,6 +1,6 @@
import { Action, State, StateContext } from '@ngxs/store';
-import { catchError, finalize, forkJoin, switchMap, tap, throwError } from 'rxjs';
+import { catchError, finalize, forkJoin, tap, throwError } from 'rxjs';
import { inject, Injectable } from '@angular/core';
@@ -8,6 +8,7 @@ import { MapProjectMetadata } from '@osf/features/project/files/mappers';
import {
CreateFolder,
DeleteEntry,
+ GetConfiguredStorageAddons,
GetFile,
GetFileMetadata,
GetFileProjectContributors,
@@ -15,8 +16,8 @@ import {
GetFileRevisions,
GetFiles,
GetMoveFileFiles,
- GetMoveFileRootFiles,
GetRootFolderFiles,
+ GetRootFolders,
RenameEntry,
SetCurrentFolder,
SetFileMetadata,
@@ -27,6 +28,7 @@ import {
UpdateTags,
} from '@osf/features/project/files/store/project-files.actions';
import { ProjectFilesStateModel } from '@osf/features/project/files/store/project-files.model';
+import { ToastService } from '@shared/services';
import { FilesService } from '@shared/services/files.service';
import { projectFilesStateDefaults } from '../models';
@@ -38,55 +40,7 @@ import { projectFilesStateDefaults } from '../models';
})
export class ProjectFilesState {
filesService = inject(FilesService);
-
- @Action(GetRootFolderFiles)
- getRootFolderFiles(ctx: StateContext, action: GetRootFolderFiles) {
- const state = ctx.getState();
- ctx.patchState({ files: { ...state.files, isLoading: true, error: null } });
-
- return this.filesService.getRootFolderFiles(action.projectId, state.provider, state.search, state.sort).pipe(
- switchMap((files) => {
- return this.filesService.getFolder(files[0].relationships.parentFolderLink).pipe(
- tap({
- next: (parentFolder) => {
- ctx.patchState({
- files: {
- data: [...files],
- isLoading: false,
- error: null,
- },
- currentFolder: parentFolder,
- });
- },
- })
- );
- }),
- catchError((error) => this.handleError(ctx, 'files', error))
- );
- }
-
- @Action(GetMoveFileRootFiles)
- getMoveFileRootFiles(ctx: StateContext, action: GetMoveFileRootFiles) {
- const state = ctx.getState();
- ctx.patchState({
- moveFileFiles: { ...state.moveFileFiles, isLoading: true, error: null },
- });
-
- return this.filesService.getRootFolderFiles(action.projectId, state.provider, '', '').pipe(
- tap({
- next: (files) => {
- ctx.patchState({
- moveFileFiles: {
- data: files,
- isLoading: false,
- error: null,
- },
- });
- },
- }),
- catchError((error) => this.handleError(ctx, 'moveFileFiles', error))
- );
- }
+ toastService = inject(ToastService);
@Action(GetMoveFileFiles)
getMoveFileFiles(ctx: StateContext, action: GetMoveFileFiles) {
@@ -154,7 +108,7 @@ export class ProjectFilesState {
ctx.patchState({ files: { ...state.files, isLoading: true, error: null } });
return this.filesService
- .createFolder(action.projectId, state.provider, action.folderName, action.folderId)
+ .createFolder(action.newFolderLink, action.folderName)
.pipe(finalize(() => ctx.patchState({ files: { ...state.files, isLoading: false, error: null } })));
}
@@ -319,6 +273,46 @@ export class ProjectFilesState {
);
}
+ @Action(GetRootFolders)
+ getRootFolders(ctx: StateContext, action: GetRootFolders) {
+ const state = ctx.getState();
+ ctx.patchState({ rootFolders: { ...state.rootFolders, isLoading: true, error: null } });
+
+ return this.filesService.getFolders(action.folderLink).pipe(
+ tap({
+ next: (folders) =>
+ ctx.patchState({
+ rootFolders: {
+ data: folders,
+ isLoading: false,
+ error: null,
+ },
+ }),
+ }),
+ catchError((error) => this.handleError(ctx, 'rootFolders', error))
+ );
+ }
+
+ @Action(GetConfiguredStorageAddons)
+ getConfiguredStorageAddons(ctx: StateContext, action: GetConfiguredStorageAddons) {
+ const state = ctx.getState();
+ ctx.patchState({ configuredStorageAddons: { ...state.configuredStorageAddons, isLoading: true, error: null } });
+
+ return this.filesService.getConfiguredStorageAddons(action.resourceUri).pipe(
+ tap({
+ next: (addons) =>
+ ctx.patchState({
+ configuredStorageAddons: {
+ data: addons,
+ isLoading: false,
+ error: null,
+ },
+ }),
+ }),
+ catchError((error) => this.handleError(ctx, 'configuredStorageAddons', error))
+ );
+ }
+
private handleError(
ctx: StateContext,
section:
@@ -329,7 +323,9 @@ export class ProjectFilesState {
| 'projectMetadata'
| 'contributors'
| 'fileRevisions'
- | 'tags',
+ | 'tags'
+ | 'rootFolders'
+ | 'configuredStorageAddons',
error: Error
) {
ctx.patchState({
@@ -339,6 +335,7 @@ export class ProjectFilesState {
error: error.message,
},
});
+ this.toastService.showError(error.message);
return throwError(() => error);
}
}
diff --git a/src/app/features/project/overview/mappers/project-overview.mapper.ts b/src/app/features/project/overview/mappers/project-overview.mapper.ts
index 5653e9fc0..b840f42b1 100644
--- a/src/app/features/project/overview/mappers/project-overview.mapper.ts
+++ b/src/app/features/project/overview/mappers/project-overview.mapper.ts
@@ -78,6 +78,10 @@ export class ProjectOverviewMapper {
region: response.relationships.region?.data,
forksCount: response.relationships.forks.links.related.meta.count,
viewOnlyLinksCount: response.relationships.view_only_links.links.related.meta.count,
+ links: {
+ rootFolder: response.relationships?.files?.links?.related?.href,
+ iri: response.links?.iri,
+ },
};
}
diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.models.ts
index 44a8edcf7..ce507c7ac 100644
--- a/src/app/features/project/overview/models/project-overview.models.ts
+++ b/src/app/features/project/overview/models/project-overview.models.ts
@@ -114,6 +114,10 @@ export interface ProjectOverview {
}[];
forksCount: number;
viewOnlyLinksCount: number;
+ links: {
+ rootFolder: string;
+ iri: string;
+ };
}
export interface ProjectOverviewSubject {
@@ -256,6 +260,16 @@ export interface ProjectOverviewGetResponseJsoApi {
};
};
};
+ files: {
+ links: {
+ related: {
+ href: string;
+ };
+ };
+ };
+ };
+ links: {
+ iri: string;
};
}
diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts
index 4c5f037ba..94cbef903 100644
--- a/src/app/features/project/project.routes.ts
+++ b/src/app/features/project/project.routes.ts
@@ -34,6 +34,9 @@ export const projectRoutes: Routes = [
path: 'files',
loadChildren: () => import('../project/files/project-files.routes').then((mod) => mod.projectFilesRoutes),
providers: [provideStates([ProjectFilesState])],
+ data: {
+ context: ResourceType.Project,
+ },
},
{
path: 'registrations',
diff --git a/src/app/features/registry/mappers/registry-overview.mapper.ts b/src/app/features/registry/mappers/registry-overview.mapper.ts
index 8c841fd64..484556521 100644
--- a/src/app/features/registry/mappers/registry-overview.mapper.ts
+++ b/src/app/features/registry/mappers/registry-overview.mapper.ts
@@ -62,5 +62,8 @@ export function MapRegistryOverview(data: RegistryOverviewJsonApiData): Registry
})),
status: MapRegistryStatus(data.attributes),
revisionStatus: data.attributes.revision_state,
+ links: {
+ files: data?.embeds?.files?.data?.[0]?.relationships?.files?.links?.related?.href,
+ },
} as RegistryOverview;
}
diff --git a/src/app/features/registry/models/get-registry-overview-json-api.model.ts b/src/app/features/registry/models/get-registry-overview-json-api.model.ts
index 99d9f1952..f7c55242c 100644
--- a/src/app/features/registry/models/get-registry-overview-json-api.model.ts
+++ b/src/app/features/registry/models/get-registry-overview-json-api.model.ts
@@ -100,6 +100,20 @@ export interface RegistryOverviewJsonApiEmbed {
};
}[];
};
+ files: {
+ data: {
+ id: string;
+ relationships: {
+ files: {
+ links: {
+ related: {
+ href: string;
+ };
+ };
+ };
+ };
+ }[];
+ };
}
export interface RegistryOverviewJsonApiRelationships {
diff --git a/src/app/features/registry/models/registry-overview.models.ts b/src/app/features/registry/models/registry-overview.models.ts
index 6ff2f5349..91cdc6d53 100644
--- a/src/app/features/registry/models/registry-overview.models.ts
+++ b/src/app/features/registry/models/registry-overview.models.ts
@@ -61,4 +61,7 @@ export interface RegistryOverview {
}[];
status: RegistryStatus;
revisionStatus: RevisionReviewStates;
+ links: {
+ files: string;
+ };
}
diff --git a/src/app/features/registry/pages/index.ts b/src/app/features/registry/pages/index.ts
index e2880e23c..de079877f 100644
--- a/src/app/features/registry/pages/index.ts
+++ b/src/app/features/registry/pages/index.ts
@@ -1 +1,2 @@
-export * from './registry-overview/registry-overview.component';
+export * from '@osf/features/registry/pages/registry-files/registry-files.component';
+export * from '@osf/features/registry/pages/registry-overview/registry-overview.component';
diff --git a/src/app/features/registry/pages/registry-files/registry-files.component.html b/src/app/features/registry/pages/registry-files/registry-files.component.html
new file mode 100644
index 000000000..d30f028b5
--- /dev/null
+++ b/src/app/features/registry/pages/registry-files/registry-files.component.html
@@ -0,0 +1,47 @@
+
+
+@if (!dataLoaded()) {
+
+} @else {
+
+}
diff --git a/src/app/features/registry/pages/registry-files/registry-files.component.scss b/src/app/features/registry/pages/registry-files/registry-files.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/app/features/registry/pages/registry-files/registry-files.component.spec.ts b/src/app/features/registry/pages/registry-files/registry-files.component.spec.ts
new file mode 100644
index 000000000..2dd8158f1
--- /dev/null
+++ b/src/app/features/registry/pages/registry-files/registry-files.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { RegistryFilesComponent } from './registry-files.component';
+
+describe('RegistryFilesComponent', () => {
+ let component: RegistryFilesComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [RegistryFilesComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(RegistryFilesComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/registry/pages/registry-files/registry-files.component.ts b/src/app/features/registry/pages/registry-files/registry-files.component.ts
new file mode 100644
index 000000000..a35ea3f08
--- /dev/null
+++ b/src/app/features/registry/pages/registry-files/registry-files.component.ts
@@ -0,0 +1,167 @@
+import { createDispatchMap, select } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { TreeDragDropService } from 'primeng/api';
+import { Button } from 'primeng/button';
+import { DialogService } from 'primeng/dynamicdialog';
+
+import { debounceTime, EMPTY, Observable, skip } from 'rxjs';
+
+import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormControl } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { FilesTreeActions } from '@osf/features/project/files/models';
+import {
+ FilesTreeComponent,
+ FormSelectComponent,
+ LoadingSpinnerComponent,
+ SearchInputComponent,
+ SubHeaderComponent,
+} from '@shared/components';
+import { ALL_SORT_OPTIONS } from '@shared/constants';
+import { OsfFile } from '@shared/models';
+import { FilesService } from '@shared/services';
+
+import {
+ GetRegistryFiles,
+ SetCurrentFolder,
+ SetSearch,
+ SetSort,
+} from '../../store/registry-files/registry-files.actions';
+import { RegistryFilesSelectors } from '../../store/registry-files/registry-files.selectors';
+import { GetRegistryById } from '../../store/registry-overview/registry-overview.actions';
+import { RegistryOverviewSelectors } from '../../store/registry-overview/registry-overview.selectors';
+
+@Component({
+ selector: 'osf-registry-files',
+ imports: [
+ SubHeaderComponent,
+ TranslatePipe,
+ Button,
+ FilesTreeComponent,
+ FormSelectComponent,
+ SearchInputComponent,
+ LoadingSpinnerComponent,
+ ],
+ templateUrl: './registry-files.component.html',
+ styleUrl: './registry-files.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [DialogService, TreeDragDropService],
+})
+export class RegistryFilesComponent {
+ protected readonly registry = select(RegistryOverviewSelectors.getRegistry);
+ protected readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading);
+ protected readonly files = select(RegistryFilesSelectors.getFiles);
+ protected readonly isFilesLoading = select(RegistryFilesSelectors.isFilesLoading);
+ protected readonly currentFolder = select(RegistryFilesSelectors.getCurrentFolder);
+ private readonly router = inject(Router);
+ private readonly route = inject(ActivatedRoute);
+ private readonly filesService = inject(FilesService);
+ private readonly destroyRef = inject(DestroyRef);
+
+ private readonly actions = createDispatchMap({
+ setCurrentFolder: SetCurrentFolder,
+ getFiles: GetRegistryFiles,
+ getRootFolderFiles: GetRegistryFiles,
+ getRegistryById: GetRegistryById,
+ setSearch: SetSearch,
+ setSort: SetSort,
+ });
+
+ protected readonly filesTreeActions: FilesTreeActions = {
+ setCurrentFolder: (folder) => this.actions.setCurrentFolder(folder),
+ getFiles: (filesLink) => this.actions.getFiles(filesLink),
+ };
+
+ protected readonly searchControl = new FormControl('');
+ protected readonly sortControl = new FormControl(ALL_SORT_OPTIONS[0].value);
+
+ protected isFolderOpening = signal(false);
+ protected registryId = signal('');
+ protected dataLoaded = signal(false);
+
+ protected readonly sortOptions = ALL_SORT_OPTIONS;
+ protected readonly provider = 'osfstorage';
+
+ constructor() {
+ this.route.parent?.params.subscribe((params) => {
+ const id = params['id'];
+ if (id) {
+ this.registryId.set(id);
+ if (!this.registry()) {
+ this.actions.getRegistryById(id);
+ }
+ }
+ });
+
+ effect(() => {
+ const registry = this.registry();
+
+ if (registry) {
+ this.actions.getFiles(registry.links.files).subscribe(() => this.dataLoaded.set(true));
+ }
+ });
+
+ this.searchControl.valueChanges
+ .pipe(skip(1), takeUntilDestroyed(this.destroyRef), debounceTime(500))
+ .subscribe((searchText) => {
+ this.actions.setSearch(searchText ?? '');
+ if (!this.isFolderOpening()) {
+ this.updateFilesList();
+ }
+ });
+
+ this.sortControl.valueChanges.pipe(skip(1), takeUntilDestroyed(this.destroyRef)).subscribe((sort) => {
+ this.actions.setSort(sort ?? '');
+ if (!this.isFolderOpening()) {
+ this.updateFilesList();
+ }
+ });
+ }
+
+ folderIsOpening(value: boolean): void {
+ this.isFolderOpening.set(value);
+ if (value) {
+ this.searchControl.setValue('');
+ this.sortControl.setValue(ALL_SORT_OPTIONS[0].value);
+ }
+ }
+
+ updateFilesList(): Observable {
+ const currentFolder = this.currentFolder();
+ if (currentFolder?.relationships.filesLink) {
+ return this.actions.getFiles(currentFolder?.relationships.filesLink).pipe(takeUntilDestroyed(this.destroyRef));
+ }
+
+ const registry = this.registry();
+
+ if (registry) {
+ return this.actions.getFiles(registry.links.files);
+ }
+
+ return EMPTY;
+ }
+
+ navigateToFile(file: OsfFile) {
+ this.router.navigate([file.guid], { relativeTo: this.route });
+ }
+
+ downloadFolder(): void {
+ const registryId = this.registry()?.id;
+ const folderId = this.currentFolder()?.id ?? '';
+ const isRootFolder = !this.currentFolder()?.relationships?.parentFolderLink;
+
+ if (registryId) {
+ if (isRootFolder || !folderId) {
+ const link = this.filesService.getFolderDownloadLink(registryId, this.provider, '', true);
+ window.open(link, '_blank')?.focus();
+ } else {
+ const link = this.filesService.getFolderDownloadLink(registryId, this.provider, folderId, false);
+ window.open(link, '_blank')?.focus();
+ }
+ }
+ }
+}
diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.scss b/src/app/features/registry/pages/registry-overview/registry-overview.component.scss
index b90d683af..3fd87ac8b 100644
--- a/src/app/features/registry/pages/registry-overview/registry-overview.component.scss
+++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.scss
@@ -24,6 +24,7 @@
flex: 1;
border: 1px solid var.$grey-2;
border-radius: mix.rem(12px);
+ height: max-content;
}
.no-padding {
diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts
index 574fa893d..fef5eec8f 100644
--- a/src/app/features/registry/registry.routes.ts
+++ b/src/app/features/registry/registry.routes.ts
@@ -2,18 +2,20 @@ import { provideStates } from '@ngxs/store';
import { Routes } from '@angular/router';
+import { RegistryFilesState } from '@osf/features/registry/store/registry-files';
+import { RegistryOverviewState } from '@osf/features/registry/store/registry-overview';
import { ResourceType } from '@osf/shared/enums';
import { ContributorsState, ViewOnlyLinkState } from '@osf/shared/stores';
import { AnalyticsState } from '../project/analytics/store';
-import { RegistryOverviewState } from './store/registry-overview';
import { RegistryComponent } from './registry.component';
export const registryRoutes: Routes = [
{
path: '',
component: RegistryComponent,
+ providers: [provideStates([RegistryOverviewState])],
children: [
{
path: '',
@@ -24,7 +26,6 @@ export const registryRoutes: Routes = [
path: 'overview',
loadComponent: () =>
import('./pages/registry-overview/registry-overview.component').then((c) => c.RegistryOverviewComponent),
- providers: [provideStates([RegistryOverviewState])],
},
{
path: 'contributors',
@@ -39,6 +40,15 @@ export const registryRoutes: Routes = [
data: { resourceType: ResourceType.Registration },
providers: [provideStates([AnalyticsState])],
},
+ {
+ path: 'files',
+ loadComponent: () =>
+ import('./pages/registry-files/registry-files.component').then((c) => c.RegistryFilesComponent),
+ providers: [provideStates([RegistryFilesState])],
+ data: {
+ context: ResourceType.Registration,
+ },
+ },
],
},
];
diff --git a/src/app/features/registry/store/registry-files/index.ts b/src/app/features/registry/store/registry-files/index.ts
new file mode 100644
index 000000000..6c68e2520
--- /dev/null
+++ b/src/app/features/registry/store/registry-files/index.ts
@@ -0,0 +1,4 @@
+export * from './registry-files.actions';
+export * from './registry-files.model';
+export * from './registry-files.selectors';
+export * from './registry-files.state';
diff --git a/src/app/features/registry/store/registry-files/registry-files.actions.ts b/src/app/features/registry/store/registry-files/registry-files.actions.ts
new file mode 100644
index 000000000..60015342f
--- /dev/null
+++ b/src/app/features/registry/store/registry-files/registry-files.actions.ts
@@ -0,0 +1,25 @@
+import { OsfFile } from '@shared/models';
+
+export class GetRegistryFiles {
+ static readonly type = '[Registry Files] Get Registry Files';
+
+ constructor(public filesLink: string) {}
+}
+
+export class SetCurrentFolder {
+ static readonly type = '[Registry Files] Set Current Folder';
+
+ constructor(public folder: OsfFile | null) {}
+}
+
+export class SetSearch {
+ static readonly type = '[Registry Files] Set Search';
+
+ constructor(public search: string) {}
+}
+
+export class SetSort {
+ static readonly type = '[Registry Files] Set Sort';
+
+ constructor(public sort: string) {}
+}
diff --git a/src/app/features/registry/store/registry-files/registry-files.model.ts b/src/app/features/registry/store/registry-files/registry-files.model.ts
new file mode 100644
index 000000000..8ee42bb5c
--- /dev/null
+++ b/src/app/features/registry/store/registry-files/registry-files.model.ts
@@ -0,0 +1,8 @@
+import { AsyncStateModel, OsfFile } from '@shared/models';
+
+export interface RegistryFilesStateModel {
+ files: AsyncStateModel;
+ search: string;
+ sort: string;
+ currentFolder: OsfFile | null;
+}
diff --git a/src/app/features/registry/store/registry-files/registry-files.selectors.ts b/src/app/features/registry/store/registry-files/registry-files.selectors.ts
new file mode 100644
index 000000000..a68bc23e4
--- /dev/null
+++ b/src/app/features/registry/store/registry-files/registry-files.selectors.ts
@@ -0,0 +1,23 @@
+import { Selector } from '@ngxs/store';
+
+import { OsfFile } from '@shared/models';
+
+import { RegistryFilesStateModel } from './registry-files.model';
+import { RegistryFilesState } from './registry-files.state';
+
+export class RegistryFilesSelectors {
+ @Selector([RegistryFilesState])
+ static getFiles(state: RegistryFilesStateModel): OsfFile[] {
+ return state.files.data;
+ }
+
+ @Selector([RegistryFilesState])
+ static isFilesLoading(state: RegistryFilesStateModel): boolean {
+ return state.files.isLoading;
+ }
+
+ @Selector([RegistryFilesState])
+ static getCurrentFolder(state: RegistryFilesStateModel): OsfFile | null {
+ return state.currentFolder;
+ }
+}
diff --git a/src/app/features/registry/store/registry-files/registry-files.state.ts b/src/app/features/registry/store/registry-files/registry-files.state.ts
new file mode 100644
index 000000000..45ccddbc7
--- /dev/null
+++ b/src/app/features/registry/store/registry-files/registry-files.state.ts
@@ -0,0 +1,70 @@
+import { Action, State, StateContext } from '@ngxs/store';
+
+import { tap } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { inject, Injectable } from '@angular/core';
+
+import { handleSectionError } from '@core/handlers';
+import { FilesService, ToastService } from '@shared/services';
+
+import { GetRegistryFiles, SetCurrentFolder, SetSearch, SetSort } from './registry-files.actions';
+import { RegistryFilesStateModel } from './registry-files.model';
+
+@Injectable()
+@State({
+ name: 'registryFiles',
+ defaults: {
+ files: {
+ data: [],
+ isLoading: false,
+ error: null,
+ },
+ search: '',
+ sort: '',
+ currentFolder: null,
+ },
+})
+export class RegistryFilesState {
+ private readonly filesService = inject(FilesService);
+ private readonly toastService = inject(ToastService);
+
+ @Action(GetRegistryFiles)
+ getRegistryFiles(ctx: StateContext, action: GetRegistryFiles) {
+ const state = ctx.getState();
+ ctx.patchState({ files: { ...state.files, isLoading: true, error: null } });
+
+ return this.filesService.getFiles(action.filesLink, state.search, state.sort).pipe(
+ tap({
+ next: (files) => {
+ ctx.patchState({
+ files: {
+ data: files,
+ isLoading: false,
+ error: null,
+ },
+ });
+ },
+ }),
+ catchError((error) => {
+ this.toastService.showError(error);
+ return handleSectionError(ctx, 'files', error);
+ })
+ );
+ }
+
+ @Action(SetCurrentFolder)
+ setSelectedFolder(ctx: StateContext, action: SetCurrentFolder) {
+ ctx.patchState({ currentFolder: action.folder });
+ }
+
+ @Action(SetSearch)
+ setSearch(ctx: StateContext, action: SetSearch) {
+ ctx.patchState({ search: action.search });
+ }
+
+ @Action(SetSort)
+ setSort(ctx: StateContext, action: SetSort) {
+ ctx.patchState({ sort: action.sort });
+ }
+}
diff --git a/src/app/features/registry/store/registry-overview/registry-overview.model.ts b/src/app/features/registry/store/registry-overview/registry-overview.model.ts
index c60c1306e..ff735cd99 100644
--- a/src/app/features/registry/store/registry-overview/registry-overview.model.ts
+++ b/src/app/features/registry/store/registry-overview/registry-overview.model.ts
@@ -1,5 +1,9 @@
-import { RegistryInstitution, RegistryOverview, RegistrySubject } from '@osf/features/registry/models';
-import { RegistrySchemaBlock } from '@osf/features/registry/models/registry-schema-block.model';
+import {
+ RegistryInstitution,
+ RegistryOverview,
+ RegistrySchemaBlock,
+ RegistrySubject,
+} from '@osf/features/registry/models';
import { AsyncStateModel } from '@shared/models';
export interface RegistryOverviewStateModel {
diff --git a/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts b/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts
index bfac7b37c..395b5c292 100644
--- a/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts
+++ b/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts
@@ -1,6 +1,11 @@
import { Selector } from '@ngxs/store';
-import { RegistryInstitution, RegistryOverview, RegistrySchemaBlock, RegistrySubject } from '../../models';
+import {
+ RegistryInstitution,
+ RegistryOverview,
+ RegistrySchemaBlock,
+ RegistrySubject,
+} from '@osf/features/registry/models';
import { RegistryOverviewStateModel } from './registry-overview.model';
import { RegistryOverviewState } from './registry-overview.state';
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 87293b098..5eab5f92d 100644
--- a/src/app/shared/components/files-tree/files-tree.component.html
+++ b/src/app/shared/components/files-tree/files-tree.component.html
@@ -1,3 +1,12 @@
+
+ @if (isDragOver()) {
+
+
+
{{ 'project.files.dropText' | translate }}
+
+ }
+
+
@if (isLoading()) {
@@ -26,18 +35,8 @@