From 8a93bf2f051c2413df3228d171e945e55b49cbf9 Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Thu, 4 Sep 2025 19:59:45 +0300 Subject: [PATCH 1/7] feat(file): file-widget --- .../files/pages/files/files.component.html | 4 +- .../files/pages/files/files.component.ts | 47 ++--- src/app/features/files/store/files.actions.ts | 5 +- src/app/features/files/store/files.model.ts | 5 +- .../features/files/store/files.selectors.ts | 5 + src/app/features/files/store/files.state.ts | 11 +- .../files-widget/files-widget.component.html | 51 +++++ .../files-widget/files-widget.component.scss | 4 + .../files-widget.component.spec.ts | 22 ++ .../files-widget/files-widget.component.ts | 189 ++++++++++++++++++ .../project/overview/components/index.ts | 1 + .../overview/project-overview.component.html | 5 + .../overview/project-overview.component.scss | 14 +- .../overview/project-overview.component.ts | 49 +++-- .../store/project-overview.actions.ts | 6 + .../overview/store/project-overview.model.ts | 8 + .../store/project-overview.selectors.ts | 10 + .../overview/store/project-overview.state.ts | 27 +++ src/app/features/registries/mappers/index.ts | 1 - .../registries/mappers/projects.mapper.ts | 10 - src/app/features/registries/services/index.ts | 1 - .../registries/services/projects.service.ts | 34 +--- .../store/handlers/projects.handlers.ts | 10 +- .../files-tree/files-tree.component.html | 19 +- .../files-tree/files-tree.component.scss | 9 +- .../files-tree/files-tree.component.ts | 81 ++++---- .../files/files-tree-actions.interface.ts | 2 +- .../projects/projects-json-api.models.ts | 4 +- src/app/shared/services/files.service.ts | 20 +- src/app/shared/services/projects.service.ts | 28 ++- src/assets/i18n/en.json | 5 +- src/assets/styles/overrides/tree.scss | 14 ++ 32 files changed, 545 insertions(+), 156 deletions(-) create mode 100644 src/app/features/project/overview/components/files-widget/files-widget.component.html create mode 100644 src/app/features/project/overview/components/files-widget/files-widget.component.scss create mode 100644 src/app/features/project/overview/components/files-widget/files-widget.component.spec.ts create mode 100644 src/app/features/project/overview/components/files-widget/files-widget.component.ts delete mode 100644 src/app/features/registries/mappers/projects.mapper.ts diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 6980c972c..448f8d204 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -20,7 +20,7 @@

{{ option.label }}

- + @@ -101,6 +101,7 @@ diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 176fc245b..530b68e0d 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -10,7 +10,7 @@ import { FloatLabel } from 'primeng/floatlabel'; import { Select } from 'primeng/select'; import { TableModule } from 'primeng/table'; -import { debounceTime, EMPTY, filter, finalize, Observable, skip, take } from 'rxjs'; +import { debounceTime, distinctUntilChanged, EMPTY, filter, finalize, skip, switchMap, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; import { @@ -110,6 +110,7 @@ export class FilesComponent { isMedium = toSignal(inject(IS_MEDIUM)); readonly files = select(FilesSelectors.getFiles); + readonly filesTotalCount = select(FilesSelectors.getFilesTotalCount); readonly isFilesLoading = select(FilesSelectors.isFilesLoading); readonly currentFolder = select(FilesSelectors.getCurrentFolder); readonly provider = select(FilesSelectors.getProvider); @@ -134,6 +135,7 @@ export class FilesComponent { sortOptions = ALL_SORT_OPTIONS; storageProvider = FileProvider.OsfStorage; + pageNumber = signal(1); private readonly urlMap = new Map([ [ResourceType.Project, 'nodes'], @@ -167,7 +169,7 @@ export class FilesComponent { readonly filesTreeActions: FilesTreeActions = { setCurrentFolder: (folder) => this.actions.setCurrentFolder(folder), setFilesIsLoading: (isLoading) => this.actions.setFilesIsLoading(isLoading), - getFiles: (filesLink) => this.actions.getFiles(filesLink), + getFiles: (filesLink) => this.actions.getFiles(filesLink, this.pageNumber()), deleteEntry: (resourceId, link) => this.actions.deleteEntry(resourceId, link), renameEntry: (resourceId, link, newName) => this.actions.renameEntry(resourceId, link, newName), setMoveFileCurrentFolder: (folder) => this.actions.setMoveFileCurrentFolder(folder), @@ -218,7 +220,7 @@ export class FilesComponent { }); this.searchControl.valueChanges - .pipe(skip(1), takeUntilDestroyed(this.destroyRef), debounceTime(500)) + .pipe(skip(1), takeUntilDestroyed(this.destroyRef), distinctUntilChanged(), debounceTime(500)) .subscribe((searchText) => { this.actions.setSearch(searchText ?? ''); if (!this.isFolderOpening()) { @@ -263,15 +265,6 @@ export class FilesComponent { if (event.type === HttpEventType.UploadProgress && event.total) { this.progress.set(Math.round((event.loaded / event.total) * 100)); } - // [NM] Check if need to create guid here - // if (event.type === HttpEventType.Response) { - // if (event.body) { - // const fileId = event?.body?.data?.id?.split('/').pop(); - // if (fileId) { - // this.filesService.getFileGuid(fileId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); - // } - // } - // } }); } @@ -298,18 +291,18 @@ export class FilesComponent { modal: true, closable: true, }) - .onClose.pipe(filter((folderName: string) => !!folderName)) - .subscribe((folderName) => { - this.actions - .createFolder(newFolderLink, folderName) - .pipe( - take(1), - finalize(() => { - this.updateFilesList().subscribe(() => this.fileIsUploading.set(false)); - }) - ) - .subscribe(); - }); + .onClose.pipe( + filter((folderName: string) => !!folderName), + switchMap((folderName: string) => { + return this.actions.createFolder(newFolderLink, folderName); + }), + take(1), + finalize(() => { + this.updateFilesList(); + this.fileIsUploading.set(false); + }) + ) + .subscribe(); } downloadFolder(): void { @@ -343,7 +336,7 @@ export class FilesComponent { }); } - updateFilesList(): Observable { + updateFilesList() { const currentFolder = this.currentFolder(); if (currentFolder?.relationships.filesLink) { this.filesTreeActions.setFilesIsLoading?.(true); @@ -372,4 +365,8 @@ export class FilesComponent { return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; } } + + onFilesPageChange(page: number) { + this.pageNumber.set(page); + } } diff --git a/src/app/features/files/store/files.actions.ts b/src/app/features/files/store/files.actions.ts index a7ac92dc4..7f5e0de83 100644 --- a/src/app/features/files/store/files.actions.ts +++ b/src/app/features/files/store/files.actions.ts @@ -11,7 +11,10 @@ export class GetRootFolderFiles { export class GetFiles { static readonly type = '[Files] Get Files'; - constructor(public filesLink: string) {} + constructor( + public filesLink: string, + public page?: number + ) {} } export class SetFilesIsLoading { diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index 8b0cf19af..9afca728e 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -1,12 +1,12 @@ import { ContributorModel, OsfFile, ResourceMetadata } from '@shared/models'; import { ConfiguredStorageAddonModel } from '@shared/models/addons'; -import { AsyncStateModel } from '@shared/models/store'; +import { AsyncStateModel, AsyncStateWithTotalCount } from '@shared/models/store'; import { FileProvider } from '../constants'; import { OsfFileCustomMetadata, OsfFileRevision } from '../models'; export interface FilesStateModel { - files: AsyncStateModel; + files: AsyncStateWithTotalCount; moveFileFiles: AsyncStateModel; currentFolder: OsfFile | null; moveFileCurrentFolder: OsfFile | null; @@ -28,6 +28,7 @@ export const filesStateDefaults: FilesStateModel = { data: [], isLoading: false, error: null, + totalCount: 0, }, moveFileFiles: { data: [], diff --git a/src/app/features/files/store/files.selectors.ts b/src/app/features/files/store/files.selectors.ts index 4d1c103cf..20070f81d 100644 --- a/src/app/features/files/store/files.selectors.ts +++ b/src/app/features/files/store/files.selectors.ts @@ -13,6 +13,11 @@ export class FilesSelectors { return state.files.data; } + @Selector([FilesState]) + static getFilesTotalCount(state: FilesStateModel): number { + return state.files.totalCount; + } + @Selector([FilesState]) static isFilesLoading(state: FilesStateModel): boolean { return state.files.isLoading; diff --git a/src/app/features/files/store/files.state.ts b/src/app/features/files/store/files.state.ts index 61d6217bb..56dda5af0 100644 --- a/src/app/features/files/store/files.state.ts +++ b/src/app/features/files/store/files.state.ts @@ -52,7 +52,7 @@ export class FilesState { return this.filesService.getFiles(action.filesLink, '', '').pipe( tap({ - next: (files) => { + next: ({ data: files }) => { ctx.patchState({ moveFileFiles: { data: files, @@ -69,15 +69,16 @@ export class FilesState { @Action(GetFiles) getFiles(ctx: StateContext, action: GetFiles) { const state = ctx.getState(); - ctx.patchState({ files: { ...state.files, isLoading: true, error: null } }); - return this.filesService.getFiles(action.filesLink, state.search, state.sort).pipe( + ctx.patchState({ files: { ...state.files, isLoading: true, error: null, totalCount: 0 } }); + return this.filesService.getFiles(action.filesLink, state.search, state.sort, action.page).pipe( tap({ - next: (files) => { + next: ({ data, totalCount }) => { ctx.patchState({ files: { - data: files, + data, isLoading: false, error: null, + totalCount, }, }); }, 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 new file mode 100644 index 000000000..9ed4e263c --- /dev/null +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.html @@ -0,0 +1,51 @@ +
+

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

+ + @if (isStorageLoading) { +
+ + + +
+ } @else { + + + @for (option of storageAddons(); track option.folder.id) { + + + + {{ option.label }} + + + + } + + + } + + + +
diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.scss b/src/app/features/project/overview/components/files-widget/files-widget.component.scss new file mode 100644 index 000000000..2ca8ce5fe --- /dev/null +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.scss @@ -0,0 +1,4 @@ +:host { + border: 1px solid var(--grey-2); + border-radius: 0.75rem; +} diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.spec.ts b/src/app/features/project/overview/components/files-widget/files-widget.component.spec.ts new file mode 100644 index 000000000..e6cf5e013 --- /dev/null +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FilesWidgetComponent } from './files-widget.component'; + +describe.skip('FilesWidgetComponent', () => { + let component: FilesWidgetComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilesWidgetComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(FilesWidgetComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 new file mode 100644 index 000000000..deb63a720 --- /dev/null +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.ts @@ -0,0 +1,189 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Skeleton } from 'primeng/skeleton'; +import { TabsModule } from 'primeng/tabs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + model, + signal, +} from '@angular/core'; + +import { + FilesSelectors, + GetConfiguredStorageAddons, + GetFiles, + GetRootFolders, + ResetState, + SetCurrentFolder, + SetFilesIsLoading, +} from '@osf/features/files/store'; +import { FilesTreeComponent, SelectComponent } from '@osf/shared/components'; +import { Primitive } from '@osf/shared/helpers'; +import { ConfiguredStorageAddonModel, FilesTreeActions, OsfFile, SelectOption } from '@osf/shared/models'; +import { Project } from '@osf/shared/models/projects'; + +import { environment } from 'src/environments/environment'; + +@Component({ + selector: 'osf-files-widget', + imports: [TranslatePipe, SelectComponent, TabsModule, FilesTreeComponent, Button, Skeleton], + templateUrl: './files-widget.component.html', + styleUrl: './files-widget.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilesWidgetComponent { + rootOption = input.required(); + components = input.required[]>(); + areComponentsLoading = input(false); + + private readonly destroyRef = inject(DestroyRef); + + readonly files = select(FilesSelectors.getFiles); + readonly filesTotalCount = select(FilesSelectors.getFilesTotalCount); + readonly isFilesLoading = select(FilesSelectors.isFilesLoading); + readonly currentFolder = select(FilesSelectors.getCurrentFolder); + readonly provider = select(FilesSelectors.getProvider); + readonly rootFolders = select(FilesSelectors.getRootFolders); + readonly isRootFoldersLoading = select(FilesSelectors.isRootFoldersLoading); + readonly configuredStorageAddons = select(FilesSelectors.getConfiguredStorageAddons); + readonly isConfiguredStorageAddonsLoading = select(FilesSelectors.isConfiguredStorageAddonsLoading); + + currentRootFolder = model<{ label: string; folder: OsfFile } | null>(null); + pageNumber = signal(1); + + readonly options = computed(() => { + const components = this.components(); + return [this.rootOption(), ...this.flatComponents(components)]; + }); + + 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 []; + }); + + private readonly actions = createDispatchMap({ + getFiles: GetFiles, + setCurrentFolder: SetCurrentFolder, + setFilesIsLoading: SetFilesIsLoading, + getRootFolders: GetRootFolders, + getConfiguredStorageAddons: GetConfiguredStorageAddons, + resetState: ResetState, + }); + + readonly filesTreeActions: FilesTreeActions = { + setCurrentFolder: (folder) => this.actions.setCurrentFolder(folder), + getFiles: (filesLink) => this.actions.getFiles(filesLink, this.pageNumber()), + setFilesIsLoading: (isLoading) => this.actions.setFilesIsLoading(isLoading), + }; + + get isStorageLoading() { + return this.isConfiguredStorageAddonsLoading() || this.isRootFoldersLoading(); + } + + selectedRoot: string | null = null; + + constructor() { + effect(() => { + const rootOption = this.rootOption(); + if (rootOption) { + this.selectedRoot = rootOption.value as string; + } + }); + + effect(() => { + const projectId = this.rootOption().value; + this.getStorageAddons(projectId as string); + }); + + 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(() => { + this.destroyRef.onDestroy(() => { + this.actions.resetState(); + }); + }); + } + + private getStorageAddons(projectId: string) { + const resourcePath = 'nodes'; + const folderLink = `${environment.apiUrl}/${resourcePath}/${projectId}/files/`; + const iriLink = `${environment.webUrl}/${projectId}`; + this.actions.getRootFolders(folderLink); + this.actions.getConfiguredStorageAddons(iriLink); + } + + private flatComponents( + components: (Partial & { children?: Project[] })[] = [], + parentPath = '..' + ): SelectOption[] { + return components.flatMap((component) => { + const currentPath = parentPath ? `${parentPath}/${component.title ?? ''}` : (component.title ?? ''); + + return [ + { + value: component.id ?? '', + label: currentPath, + }, + ...this.flatComponents(component.children ?? [], currentPath), + ]; + }); + } + + private getAddonName(addons: ConfiguredStorageAddonModel[], provider: string): string { + if (provider === 'osfstorage') { + return 'Osf Storage'; + } else { + return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; + } + } + + onChangeProject(value: Primitive) { + this.getStorageAddons(value as string); + } + + onStorageChange(value: Primitive) { + const folder = this.storageAddons().find((option) => option.folder.id === value); + if (folder) { + this.currentRootFolder.set(folder); + } + } + + onFilesPageChange(page: number) { + this.pageNumber.set(page); + } +} diff --git a/src/app/features/project/overview/components/index.ts b/src/app/features/project/overview/components/index.ts index 92001bf88..bcecf171d 100644 --- a/src/app/features/project/overview/components/index.ts +++ b/src/app/features/project/overview/components/index.ts @@ -1,6 +1,7 @@ export { AddComponentDialogComponent } from './add-component-dialog/add-component-dialog.component'; export { DeleteComponentDialogComponent } from './delete-component-dialog/delete-component-dialog.component'; export { DuplicateDialogComponent } from './duplicate-dialog/duplicate-dialog.component'; +export { FilesWidgetComponent } from './files-widget/files-widget.component'; export { ForkDialogComponent } from './fork-dialog/fork-dialog.component'; export { OverviewComponentsComponent } from './overview-components/overview-components.component'; export { OverviewToolbarComponent } from './overview-toolbar/overview-toolbar.component'; diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 3206026c9..0b51b53d4 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -74,6 +74,11 @@ @if (isAdmin || canWrite) { } + diff --git a/src/app/features/project/overview/project-overview.component.scss b/src/app/features/project/overview/project-overview.component.scss index f97e5c2b6..b74d5d010 100644 --- a/src/app/features/project/overview/project-overview.component.scss +++ b/src/app/features/project/overview/project-overview.component.scss @@ -1,9 +1,17 @@ -.left-section { - flex: 3; -} +@use "assets/styles/variables" as var; .right-section { width: 23rem; border: 1px solid var(--grey-2); border-radius: 12px; + @media (max-width: var.$breakpoint-lg) { + width: 100%; + } +} + +.left-section { + width: calc(100% - 23rem - 1.5rem); + @media (max-width: var.$breakpoint-lg) { + width: 100%; + } } diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 894ff6ab0..467cf3885 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -24,6 +24,7 @@ import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-i import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { GetRootFolders } from '@osf/features/files/store'; import { SubmissionReviewStatus } from '@osf/features/moderation/enums'; import { IS_XSMALL } from '@osf/shared/helpers'; import { @@ -41,6 +42,7 @@ import { CollectionsSelectors, GetBookmarksCollectionId, GetCollectionProvider, + GetConfiguredStorageAddons, GetHomeWiki, GetLinkedResources, } from '@shared/stores'; @@ -54,6 +56,7 @@ import { } from '../../moderation/store/collections-moderation'; import { + FilesWidgetComponent, LinkedResourcesComponent, OverviewComponentsComponent, OverviewToolbarComponent, @@ -63,6 +66,7 @@ import { import { ClearProjectOverview, GetComponents, + GetComponentsTree, GetProjectById, ProjectOverviewSelectors, SetProjectCustomCitation, @@ -88,6 +92,7 @@ import { TranslatePipe, Message, RouterLink, + FilesWidgetComponent, ], providers: [DialogService], changeDetection: ChangeDetectionStrategy.OnPush, @@ -109,6 +114,9 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement isProjectLoading = select(ProjectOverviewSelectors.getProjectLoading); isCollectionProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); isReviewActionsLoading = select(CollectionsModerationSelectors.getCurrentReviewActionLoading); + componentsTree = select(ProjectOverviewSelectors.getComponentsTree); + areComponentsTreeLoading = select(ProjectOverviewSelectors.getComponentsTreeLoading); + readonly activityPageSize = 5; readonly activityDefaultPage = 1; readonly SubmissionReviewStatus = SubmissionReviewStatus; @@ -127,6 +135,9 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement clearWiki: ClearWiki, clearCollections: ClearCollections, clearCollectionModeration: ClearCollectionModeration, + getComponentsTree: GetComponentsTree, + getRootFolders: GetRootFolders, + getConfiguredStorageAddons: GetConfiguredStorageAddons, }); readonly isCollectionsRoute = computed(() => { @@ -143,7 +154,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement return this.currentReviewAction()?.toState; }); - protected showDecisionButton = computed(() => { + showDecisionButton = computed(() => { return ( this.isCollectionsRoute() && this.submissionReviewStatus() !== SubmissionReviewStatus.Removed && @@ -151,9 +162,9 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement ); }); - protected currentProject = select(ProjectOverviewSelectors.getProject); + currentProject = select(ProjectOverviewSelectors.getProject); private currentProject$ = toObservable(this.currentProject); - protected userPermissions = computed(() => { + userPermissions = computed(() => { return this.currentProject()?.currentUserPermissions || []; }); @@ -165,7 +176,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement return this.userPermissions().includes(UserPermissions.Write); } - protected resourceOverview = computed(() => { + resourceOverview = computed(() => { const project = this.currentProject(); if (project) { return MapProjectOverview(project); @@ -173,11 +184,11 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement return null; }); - protected isLoading = computed(() => { + isLoading = computed(() => { return this.isProjectLoading() || this.isCollectionProviderLoading() || this.isReviewActionsLoading(); }); - protected currentResource = computed(() => { + currentResource = computed(() => { if (this.currentProject()) { return { id: this.currentProject()!.id, @@ -191,12 +202,12 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement return null; }); - protected getDoi(): Observable { - return this.currentProject$.pipe( - filter((project) => project != null), - map((project) => project?.identifiers?.find((item) => item.category == 'doi')?.value ?? null) - ); - } + filesRootOption = computed(() => { + return { + value: this.currentProject()?.id ?? '', + label: this.currentProject()?.title ?? '', + }; + }); constructor() { super(); @@ -204,7 +215,14 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement this.setupCleanup(); } - protected onCustomCitationUpdated(citation: string): void { + getDoi(): Observable { + return this.currentProject$.pipe( + filter((project) => project != null), + map((project) => project?.identifiers?.find((item) => item.category == 'doi')?.value ?? null) + ); + } + + onCustomCitationUpdated(citation: string): void { this.actions.setProjectCustomCitation(citation); } @@ -215,13 +233,14 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement this.actions.getBookmarksId(); this.actions.getHomeWiki(ResourceType.Project, projectId); this.actions.getComponents(projectId); + this.actions.getComponentsTree(projectId); this.actions.getLinkedProjects(projectId); this.actions.getActivityLogs(projectId, this.activityDefaultPage.toString(), this.activityPageSize.toString()); this.setupDataciteViewTrackerEffect().subscribe(); } } - protected handleOpenMakeDecisionDialog() { + handleOpenMakeDecisionDialog() { const dialogWidth = this.isMobile() ? '95vw' : '600px'; this.dialogService @@ -242,7 +261,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement }); } - protected goBack(): void { + goBack(): void { const currentStatus = this.route.snapshot.queryParams['status']; const queryParams = currentStatus ? { status: currentStatus } : {}; diff --git a/src/app/features/project/overview/store/project-overview.actions.ts b/src/app/features/project/overview/store/project-overview.actions.ts index 36225ee4b..24e0fbcab 100644 --- a/src/app/features/project/overview/store/project-overview.actions.ts +++ b/src/app/features/project/overview/store/project-overview.actions.ts @@ -68,3 +68,9 @@ export class GetComponents { constructor(public projectId: string) {} } + +export class GetComponentsTree { + static readonly type = '[Project Overview] Get Components Tree'; + + constructor(public projectId: string) {} +} diff --git a/src/app/features/project/overview/store/project-overview.model.ts b/src/app/features/project/overview/store/project-overview.model.ts index 9f7241d0c..f41aa3754 100644 --- a/src/app/features/project/overview/store/project-overview.model.ts +++ b/src/app/features/project/overview/store/project-overview.model.ts @@ -1,10 +1,12 @@ import { AsyncStateModel, ComponentOverview } from '@osf/shared/models'; +import { Project } from '@osf/shared/models/projects'; import { ProjectOverview } from '../models'; export interface ProjectOverviewStateModel { project: AsyncStateModel; components: AsyncStateModel; + componentsTree: AsyncStateModel; } export const PROJECT_OVERVIEW_DEFAULTS: ProjectOverviewStateModel = { @@ -20,4 +22,10 @@ export const PROJECT_OVERVIEW_DEFAULTS: ProjectOverviewStateModel = { isSubmitting: false, error: null, }, + componentsTree: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, }; diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index 6378e3ab9..70f835e0e 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -29,6 +29,16 @@ export class ProjectOverviewSelectors { return state.components.isSubmitting; } + @Selector([ProjectOverviewState]) + static getComponentsTree(state: ProjectOverviewStateModel) { + return state.componentsTree.data; + } + + @Selector([ProjectOverviewState]) + static getComponentsTreeLoading(state: ProjectOverviewStateModel) { + return state.componentsTree.isLoading; + } + @Selector([ProjectOverviewState]) static getForkProjectSubmitting(state: ProjectOverviewStateModel) { return state.project.isSubmitting; diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index 52443f7e3..638373355 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -4,6 +4,7 @@ import { catchError, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ProjectsService } from '@osf/shared/services/projects.service'; import { ResourceType } from '@shared/enums'; import { ProjectOverviewService } from '../services'; @@ -15,6 +16,7 @@ import { DuplicateProject, ForkResource, GetComponents, + GetComponentsTree, GetProjectById, SetProjectCustomCitation, UpdateProjectPublicStatus, @@ -28,6 +30,7 @@ import { PROJECT_OVERVIEW_DEFAULTS, ProjectOverviewStateModel } from './project- @Injectable() export class ProjectOverviewState { projectOverviewService = inject(ProjectOverviewService); + projectsService = inject(ProjectsService); @Action(GetProjectById) getProjectById(ctx: StateContext, action: GetProjectById) { @@ -243,6 +246,30 @@ export class ProjectOverviewState { ); } + @Action(GetComponentsTree) + getComponentsTree(ctx: StateContext, action: GetComponentsTree) { + const state = ctx.getState(); + ctx.patchState({ + componentsTree: { + ...state.componentsTree, + isLoading: true, + }, + }); + + return this.projectsService.getComponentsTree(action.projectId).pipe( + tap((components) => { + ctx.patchState({ + componentsTree: { + data: components, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'componentsTree', error)) + ); + } + private handleError( ctx: StateContext, section: keyof ProjectOverviewStateModel, diff --git a/src/app/features/registries/mappers/index.ts b/src/app/features/registries/mappers/index.ts index 91c916d86..cea020f3c 100644 --- a/src/app/features/registries/mappers/index.ts +++ b/src/app/features/registries/mappers/index.ts @@ -1,3 +1,2 @@ export * from './licenses.mapper'; -export * from './projects.mapper'; export * from './providers.mapper'; diff --git a/src/app/features/registries/mappers/projects.mapper.ts b/src/app/features/registries/mappers/projects.mapper.ts deleted file mode 100644 index df0729851..000000000 --- a/src/app/features/registries/mappers/projects.mapper.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Project, ProjectsResponseJsonApi } from '../models'; - -export class ProjectsMapper { - static fromProjectsResponse(response: ProjectsResponseJsonApi): Project[] { - return response.data.map((item) => ({ - id: item.id, - title: item.attributes.title, - })); - } -} diff --git a/src/app/features/registries/services/index.ts b/src/app/features/registries/services/index.ts index c4eb84e78..3aed2743d 100644 --- a/src/app/features/registries/services/index.ts +++ b/src/app/features/registries/services/index.ts @@ -1,5 +1,4 @@ export * from './licenses.service'; -export * from './projects.service'; export * from './providers.service'; export * from './registration-files.service'; export * from './registries.service'; diff --git a/src/app/features/registries/services/projects.service.ts b/src/app/features/registries/services/projects.service.ts index 95b8b3b55..457641c87 100644 --- a/src/app/features/registries/services/projects.service.ts +++ b/src/app/features/registries/services/projects.service.ts @@ -1,12 +1,12 @@ -import { forkJoin, map, Observable, of, switchMap } from 'rxjs'; +import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ProjectsMapper } from '@osf/shared/mappers/projects'; +import { ProjectsResponseJsonApi } from '@osf/shared/models/projects'; import { JsonApiService } from '@osf/shared/services'; -import { ProjectsMapper } from '../mappers/projects.mapper'; import { Project } from '../models'; -import { ProjectsResponseJsonApi } from '../models/projects-json-api.model'; import { environment } from 'src/environments/environment'; @@ -23,32 +23,6 @@ export class ProjectsService { }; return this.jsonApiService .get(`${this.apiUrl}/users/me/nodes/`, params) - .pipe(map((response) => ProjectsMapper.fromProjectsResponse(response))); - } - - getProjectChildren(id: string): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/nodes/${id}/children`) - .pipe(map((response) => ProjectsMapper.fromProjectsResponse(response))); - } - - getComponentsTree(id: string): Observable { - return this.getProjectChildren(id).pipe( - switchMap((children) => { - if (!children.length) { - return of([]); - } - const childrenWithSubtrees$ = children.map((child) => - this.getComponentsTree(child.id).pipe( - map((subChildren) => ({ - ...child, - children: subChildren, - })) - ) - ); - - return childrenWithSubtrees$.length ? forkJoin(childrenWithSubtrees$) : of([]); - }) - ); + .pipe(map((response) => ProjectsMapper.fromGetAllProjectsResponse(response))); } } diff --git a/src/app/features/registries/store/handlers/projects.handlers.ts b/src/app/features/registries/store/handlers/projects.handlers.ts index 75fce07c9..75a405a65 100644 --- a/src/app/features/registries/store/handlers/projects.handlers.ts +++ b/src/app/features/registries/store/handlers/projects.handlers.ts @@ -2,8 +2,9 @@ import { StateContext } from '@ngxs/store'; import { inject, Injectable } from '@angular/core'; +import { ProjectsService } from '@osf/shared/services/projects.service'; + import { Project } from '../../models'; -import { ProjectsService } from '../../services'; import { DefaultState } from '../default.state'; import { RegistriesStateModel } from '../registries.model'; @@ -12,13 +13,18 @@ export class ProjectsHandlers { projectsService = inject(ProjectsService); getProjects({ patchState }: StateContext) { + const params: Record = { + 'filter[current_user_permissions]': 'admin', + }; + patchState({ projects: { ...DefaultState.projects, isLoading: true, }, }); - return this.projectsService.getProjects().subscribe({ + // [NM] TODO: check if need to change 'me' to user id + return this.projectsService.fetchProjects('me', params).subscribe({ next: (projects: Project[]) => { patchState({ projects: { 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 75189dbcd..b2f6c7fea 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -43,7 +43,7 @@ } @else {
-
+
@if (file.kind !== 'folder') {
@@ -90,15 +90,22 @@ } - + @if (totalCount() > itemsPerPage) { + + } @if (!files().length) { -
+
@if (viewOnly()) { -

{{ 'files.emptyState' | translate }}

+

{{ 'files.emptyState' | translate }}

} @else { -
+
-

{{ 'files.dropText' | translate }}

+

{{ '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 10b30e8c3..752919401 100644 --- a/src/app/shared/components/files-tree/files-tree.component.scss +++ b/src/app/shared/components/files-tree/files-tree.component.scss @@ -24,7 +24,6 @@ grid-template-rows: mix.rem(44px); border-bottom: 1px solid var.$grey-2; padding: 0 mix.rem(12px); - min-width: max-content; cursor: pointer; &:hover { @@ -43,7 +42,7 @@ } > .table-cell:first-child { - min-width: 0; + min-width: 300px; max-width: 95%; } } @@ -94,10 +93,4 @@ background: rgba(132, 174, 210, 0.5); pointer-events: all; } - - .drop-text { - text-transform: none; - color: var.$white; - pointer-events: none; - } } 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 35b28b39e..ebc269a04 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -2,10 +2,10 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PrimeTemplate } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; +import { PaginatorState } from 'primeng/paginator'; import { Tree, TreeNodeDropEvent } from 'primeng/tree'; -import { EMPTY, finalize, firstValueFrom, Observable, take, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { EMPTY, finalize, firstValueFrom, Observable, take } from 'rxjs'; import { DatePipe } from '@angular/common'; import { @@ -28,7 +28,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { MoveFileDialogComponent, RenameFileDialogComponent } from '@osf/features/files/components'; import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; import { FileMenuType } from '@osf/shared/enums'; -import { FileMenuComponent, LoadingSpinnerComponent } from '@shared/components'; +import { CustomPaginatorComponent, FileMenuComponent, LoadingSpinnerComponent } from '@shared/components'; import { StopPropagationDirective } from '@shared/directives'; import { FileMenuAction, FilesTreeActions, OsfFile } from '@shared/models'; import { FileSizePipe } from '@shared/pipes'; @@ -47,6 +47,7 @@ import { environment } from 'src/environments/environment'; LoadingSpinnerComponent, FileMenuComponent, StopPropagationDirective, + CustomPaginatorComponent, ], templateUrl: './files-tree.component.html', styleUrl: './files-tree.component.scss', @@ -64,6 +65,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { readonly translateService = inject(TranslateService); files = input.required(); + totalCount = input(0); isLoading = input(); currentFolder = input.required(); resourceId = input.required(); @@ -76,20 +78,29 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { entryFileClicked = output(); folderIsOpening = output(); uploadFileConfirmed = output(); + filesPageChange = output(); - protected readonly FileMenuType = FileMenuType; + itemsPerPage = 10; + first = 0; + hasParentFolder = false; + folderStack: OsfFile[] = []; - protected readonly nodes = computed(() => { - if (this.currentFolder()?.relationships?.parentFolderLink) { + readonly FileMenuType = FileMenuType; + + readonly nodes = computed(() => { + const currentFolder = this.currentFolder(); + const files = this.files(); + const hasParent = this.folderStack.length > 0; + if (hasParent) { return [ { - ...this.currentFolder(), - previousFolder: true, + ...currentFolder, + previousFolder: hasParent, }, - ...this.files(), + ...files, ] as OsfFile[]; } else { - return this.files(); + return [...files]; } }); @@ -140,9 +151,8 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { constructor() { effect(() => { const currentFolder = this.currentFolder(); - if (currentFolder) { - this.updateFilesList().subscribe(() => this.folderIsOpening.emit(false)); + this.updateFilesList(currentFolder).subscribe(() => this.folderIsOpening.emit(false)); } }); } @@ -157,35 +167,22 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { }); } } else { + const current = this.currentFolder(); + if (current) { + this.folderStack.push(current); + } + this.resetPagination(); this.actions().setFilesIsLoading?.(true); this.folderIsOpening.emit(true); - this.actions().setCurrentFolder(file); } } openParentFolder() { - const currentFolder = this.currentFolder(); - - if (!currentFolder) return; - - this.actions().setFilesIsLoading?.(true); - this.folderIsOpening.emit(true); - - this.filesService - .getFolder(currentFolder.relationships.parentFolderLink) - .pipe( - take(1), - catchError((error) => { - this.toastService.showError(error.error.message); - return throwError(() => error); - }) - ) - .subscribe({ - next: (folder) => { - this.actions().setCurrentFolder(folder); - }, - }); + const previous = this.folderStack.pop(); + if (previous) { + this.actions().setCurrentFolder(previous); + } } onFileMenuAction(action: FileMenuAction, file: OsfFile): void { @@ -344,10 +341,8 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { }); } - updateFilesList(): Observable { - const currentFolder = this.currentFolder(); - - if (currentFolder?.relationships.filesLink) { + updateFilesList(currentFolder: OsfFile): Observable { + if (currentFolder?.relationships?.filesLink) { return this.actions().getFiles(currentFolder?.relationships.filesLink); } return EMPTY; @@ -432,4 +427,14 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } }); } + + resetPagination() { + this.first = 0; + this.filesPageChange.emit(1); + } + + onFilesPageChange(event: PaginatorState): void { + this.filesPageChange.emit(event.page! + 1); + this.first = event.first!; + } } diff --git a/src/app/shared/models/files/files-tree-actions.interface.ts b/src/app/shared/models/files/files-tree-actions.interface.ts index 404b9bbaf..35b7993a7 100644 --- a/src/app/shared/models/files/files-tree-actions.interface.ts +++ b/src/app/shared/models/files/files-tree-actions.interface.ts @@ -5,7 +5,7 @@ import { OsfFile } from '@shared/models'; export interface FilesTreeActions { setCurrentFolder: (folder: OsfFile | null) => Observable; setFilesIsLoading?: (isLoading: boolean) => void; - getFiles: (filesLink: string) => Observable; + getFiles: (filesLink: string, page?: number) => Observable; deleteEntry?: (projectId: string, link: string) => Observable; renameEntry?: (projectId: string, link: string, newName: string) => Observable; setMoveFileCurrentFolder?: (folder: OsfFile | null) => Observable; diff --git a/src/app/shared/models/projects/projects-json-api.models.ts b/src/app/shared/models/projects/projects-json-api.models.ts index 6465c18e1..96c71af6f 100644 --- a/src/app/shared/models/projects/projects-json-api.models.ts +++ b/src/app/shared/models/projects/projects-json-api.models.ts @@ -1,4 +1,4 @@ -import { JsonApiResponse, LicenseRecordJsonApi } from '@shared/models'; +import { JsonApiResponse, LicenseRecordJsonApi, MetaJsonApi, PaginationLinksJsonApi } from '@shared/models'; export interface ProjectJsonApi { id: string; @@ -16,6 +16,8 @@ export interface ProjectJsonApi { export interface ProjectsResponseJsonApi extends JsonApiResponse { data: ProjectJsonApi[]; + meta: MetaJsonApi; + links: PaginationLinksJsonApi; } export interface ProjectRelationshipsJsonApi { diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 80654475e..02532b90a 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -24,6 +24,7 @@ import { ConfiguredStorageAddonModel, ContributorModel, ContributorResponse, + FileData, FileLinks, FileRelationshipsResponse, FileResponse, @@ -34,6 +35,7 @@ import { JsonApiResponse, OsfFile, OsfFileVersion, + ResponseJsonApi, } from '@shared/models'; import { JsonApiService } from '@shared/services'; import { ToastService } from '@shared/services/toast.service'; @@ -56,16 +58,26 @@ export class FilesService { [ResourceType.Registration, 'registrations'], ]); - getFiles(filesLink: string, search: string, sort: string): Observable { + getFiles( + filesLink: string, + search: string, + sort: string, + page = 1 + ): Observable<{ data: OsfFile[]; totalCount: number }> { const params: Record = { sort: sort, + page: page.toString(), 'fields[files]': this.filesFields, 'filter[name]': search, }; - return this.jsonApiService - .get(`${filesLink}`, params) - .pipe(map((response) => MapFiles(response.data))); + return this.jsonApiService.get>(`${filesLink}`, params).pipe( + map((response) => { + const data = MapFiles(response.data); + const totalCount = response.meta?.total || 0; + return { data, totalCount }; + }) + ); } getFolders(folderLink: string): Observable { diff --git a/src/app/shared/services/projects.service.ts b/src/app/shared/services/projects.service.ts index 0082b7f80..96d3e6249 100644 --- a/src/app/shared/services/projects.service.ts +++ b/src/app/shared/services/projects.service.ts @@ -1,4 +1,4 @@ -import { map, Observable } from 'rxjs'; +import { forkJoin, map, Observable, of, switchMap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; @@ -28,4 +28,30 @@ export class ProjectsService { .patch(`${environment.apiUrl}/nodes/${metadata.id}/`, payload) .pipe(map((response) => ProjectsMapper.fromProjectResponse(response))); } + + getProjectChildren(id: string): Observable { + return this.jsonApiService + .get(`${environment.apiUrl}/nodes/${id}/children/`) + .pipe(map((response) => ProjectsMapper.fromGetAllProjectsResponse(response))); + } + + getComponentsTree(id: string): Observable { + return this.getProjectChildren(id).pipe( + switchMap((children) => { + if (!children.length) { + return of([]); + } + const childrenWithSubtrees$ = children.map((child) => + this.getComponentsTree(child.id).pipe( + map((subChildren) => ({ + ...child, + children: subChildren, + })) + ) + ); + + return childrenWithSubtrees$.length ? forkJoin(childrenWithSubtrees$) : of([]); + }) + ); + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 5bdf80f05..5243d1e81 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -547,6 +547,9 @@ "title": "Wiki", "noWikiMessage": "Add important information, links, or images here to describe your project." }, + "files": { + "title": "Files" + }, "components": { "title": "Components", "addComponentButton": "Add Component", @@ -992,7 +995,7 @@ "helpGuides": "help guides on project files." }, "dropText": "Drop a file to upload", - "emptyState": "This folder is empty", + "emptyState": "No files are available at this time.", "detail": { "backToList": "Back to list of files", "tabs": { diff --git a/src/assets/styles/overrides/tree.scss b/src/assets/styles/overrides/tree.scss index 60b70baff..8cb72c43a 100644 --- a/src/assets/styles/overrides/tree.scss +++ b/src/assets/styles/overrides/tree.scss @@ -50,4 +50,18 @@ display: none; } } + + .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; + } + } } From ba44a9f1d711b21d9eadfe71f5b8cfe11d5797ec Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Fri, 5 Sep 2025 19:49:08 +0300 Subject: [PATCH 2/7] feat(file): fixed move file --- .../move-file-dialog.component.html | 18 +-- .../move-file-dialog.component.ts | 106 +++++++++++------- .../files/pages/files/files.component.html | 1 + .../files/pages/files/files.component.ts | 4 +- src/app/features/files/store/files.actions.ts | 5 +- src/app/features/files/store/files.model.ts | 3 +- .../features/files/store/files.selectors.ts | 5 + src/app/features/files/store/files.state.ts | 3 +- .../files-widget/files-widget.component.html | 1 + .../files-widget/files-widget.component.ts | 24 ++-- .../files-tree/files-tree.component.html | 2 +- .../files-tree/files-tree.component.ts | 21 +++- ...ar-metadata-data-template-json-api.mock.ts | 2 +- .../shared/models/files/file-label.model.ts | 6 + src/app/shared/models/files/index.ts | 1 + 15 files changed, 133 insertions(+), 69 deletions(-) create mode 100644 src/app/shared/models/files/file-label.model.ts diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.html b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.html index dc92d7934..9dd1c197b 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.html +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.html @@ -5,12 +5,11 @@ } @else {
- cost-shield -

{{ 'files.dialogs.moveFile.storage' | translate }}

+

{{ storageName }}

- @if (currentFolder()?.relationships?.parentFolderLink) { + @if (previousFolder) {
{{ 'files.dialogs.moveFile.storage' | translate }} } @for (file of files(); track $index) { -
+
@if (file.kind !== 'folder') { @@ -55,6 +51,14 @@

{{ 'files.dialogs.moveFile.storage' | translate }}

} + @if (filesTotalCount() > itemsPerPage) { + + } @if (!files().length) {

{{ 'files.emptyState' | translate }}

diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts index 0b4ba48e4..0c2dc59db 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts @@ -4,13 +4,13 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { PaginatorState } from 'primeng/paginator'; import { Tooltip } from 'primeng/tooltip'; -import { finalize, take, throwError } from 'rxjs'; +import { finalize, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { NgOptimizedImage } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { @@ -21,13 +21,13 @@ import { SetCurrentFolder, SetMoveFileCurrentFolder, } from '@osf/features/files/store'; -import { IconComponent, LoadingSpinnerComponent } from '@shared/components'; +import { CustomPaginatorComponent, IconComponent, LoadingSpinnerComponent } from '@shared/components'; import { OsfFile } from '@shared/models'; import { FilesService, ToastService } from '@shared/services'; @Component({ selector: 'osf-move-file-dialog', - imports: [Button, LoadingSpinnerComponent, NgOptimizedImage, Tooltip, TranslatePipe, IconComponent], + imports: [Button, LoadingSpinnerComponent, Tooltip, TranslatePipe, IconComponent, CustomPaginatorComponent], templateUrl: './move-file-dialog.component.html', styleUrl: './move-file-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -41,17 +41,23 @@ export class MoveFileDialogComponent { private readonly translateService = inject(TranslateService); private readonly toastService = inject(ToastService); - protected readonly files = select(FilesSelectors.getMoveFileFiles); - protected readonly isLoading = select(FilesSelectors.isMoveFileFilesLoading); - protected readonly currentFolder = select(FilesSelectors.getMoveFileCurrentFolder); - private readonly rootFolders = select(FilesSelectors.getRootFolders); - protected readonly isFilesUpdating = signal(false); - protected readonly isFolderSame = computed(() => { + readonly files = select(FilesSelectors.getMoveFileFiles); + readonly filesTotalCount = select(FilesSelectors.getMoveFileFilesTotalCount); + readonly isLoading = select(FilesSelectors.isMoveFileFilesLoading); + readonly currentFolder = select(FilesSelectors.getMoveFileCurrentFolder); + readonly isFilesUpdating = signal(false); + readonly rootFolders = select(FilesSelectors.getRootFolders); + + readonly isFolderSame = computed(() => { return this.currentFolder()?.id === this.config.data.file.relationships.parentFolderId; }); - protected readonly provider = select(FilesSelectors.getProvider); - protected readonly dispatch = createDispatchMap({ + readonly storageName = + this.config.data.storageName || this.translateService.instant('files.dialogs.moveFile.osfStorage'); + + readonly provider = select(FilesSelectors.getProvider); + + readonly dispatch = createDispatchMap({ getMoveFileFiles: GetMoveFileFiles, setMoveFileCurrentFolder: SetMoveFileCurrentFolder, setCurrentFolder: SetCurrentFolder, @@ -59,46 +65,59 @@ export class MoveFileDialogComponent { getRootFolderFiles: GetRootFolderFiles, }); + foldersStack: OsfFile[] = this.config.data.foldersStack ?? []; + previousFolder: OsfFile | null = null; + + pageNumber = signal(1); + + itemsPerPage = 10; + first = 0; + filesLink = ''; + constructor() { - const filesLink = this.currentFolder()?.relationships.filesLink; + this.initPreviousFolder(); + const filesLink = this.currentFolder()?.relationships?.filesLink; const rootFolders = this.rootFolders(); - if (filesLink) { - this.dispatch.getMoveFileFiles(filesLink); - } else if (rootFolders) { - this.dispatch.getMoveFileFiles(rootFolders[0].relationships.filesLink); + this.filesLink = filesLink ?? rootFolders?.[0].relationships?.filesLink ?? ''; + if (this.filesLink) { + this.dispatch.getMoveFileFiles(this.filesLink, this.pageNumber()); + } + + effect(() => { + const page = this.pageNumber(); + if (this.filesLink) { + this.dispatch.getMoveFileFiles(this.filesLink, page); + } + }); + } + + initPreviousFolder() { + const foldersStack = this.foldersStack; + if (foldersStack.length === 0) { + this.previousFolder = null; + } else { + this.previousFolder = foldersStack[foldersStack.length - 1]; } } openFolder(file: OsfFile) { if (file.kind !== 'folder') return; - + const current = this.currentFolder(); + if (current) { + this.previousFolder = current; + this.foldersStack.push(current); + } this.dispatch.getMoveFileFiles(file.relationships.filesLink); this.dispatch.setMoveFileCurrentFolder(file); } openParentFolder() { - const currentFolder = this.currentFolder(); - - if (!currentFolder) return; - - this.isFilesUpdating.set(true); - this.filesService - .getFolder(currentFolder.relationships.parentFolderLink) - .pipe( - take(1), - takeUntilDestroyed(this.destroyRef), - finalize(() => { - this.isFilesUpdating.set(false); - }), - catchError((error) => { - this.toastService.showError(error.error.message); - return throwError(() => error); - }) - ) - .subscribe((folder) => { - this.dispatch.setMoveFileCurrentFolder(folder); - this.dispatch.getMoveFileFiles(folder.relationships.filesLink); - }); + const previous = this.foldersStack.pop() ?? null; + this.previousFolder = this.foldersStack.length > 0 ? this.foldersStack[this.foldersStack.length - 1] : null; + if (previous) { + this.dispatch.setMoveFileCurrentFolder(previous); + this.dispatch.getMoveFileFiles(previous.relationships.filesLink); + } } moveFile(): void { @@ -148,4 +167,9 @@ export class MoveFileDialogComponent { } }); } + + onFilesPageChange(event: PaginatorState): void { + this.pageNumber.set(event.page! + 1); + this.first = event.first!; + } } diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 56b9c2132..248fd0690 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -107,6 +107,7 @@ [files]="files()" [totalCount]="filesTotalCount()" [currentFolder]="currentFolder()" + [storage]="currentRootFolder()" [isLoading]="isFilesLoading()" [actions]="filesTreeActions" [viewOnly]="isViewOnly()" diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index b367bc4fb..bdbc4f6f1 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -53,7 +53,7 @@ import { SubHeaderComponent, } from '@shared/components'; import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link-message/view-only-link-message.component'; -import { ConfiguredStorageAddonModel, FilesTreeActions, OsfFile } from '@shared/models'; +import { ConfiguredStorageAddonModel, FileLabelModel, FilesTreeActions, OsfFile } from '@shared/models'; import { FilesService } from '@shared/services'; import { CreateFolderDialogComponent, FileBrowserInfoComponent } from '../../components'; @@ -132,7 +132,7 @@ export class FilesComponent { readonly searchControl = new FormControl(''); readonly sortControl = new FormControl(ALL_SORT_OPTIONS[0].value); - currentRootFolder = model<{ label: string; folder: OsfFile } | null>(null); + currentRootFolder = model(null); fileIsUploading = signal(false); isFolderOpening = signal(false); diff --git a/src/app/features/files/store/files.actions.ts b/src/app/features/files/store/files.actions.ts index 7f5e0de83..32138b227 100644 --- a/src/app/features/files/store/files.actions.ts +++ b/src/app/features/files/store/files.actions.ts @@ -60,7 +60,10 @@ export class SetMoveFileCurrentFolder { export class GetMoveFileFiles { static readonly type = '[Files] Get Move File Files'; - constructor(public filesLink: string) {} + constructor( + public filesLink: string, + public page?: number + ) {} } export class GetFile { diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index f5a9f6340..3cb6baac1 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -7,7 +7,7 @@ import { OsfFileCustomMetadata, OsfFileRevision } from '../models'; export interface FilesStateModel { files: AsyncStateWithTotalCount; - moveFileFiles: AsyncStateModel; + moveFileFiles: AsyncStateWithTotalCount; currentFolder: OsfFile | null; moveFileCurrentFolder: OsfFile | null; search: string; @@ -35,6 +35,7 @@ export const filesStateDefaults: FilesStateModel = { data: [], isLoading: false, error: null, + totalCount: 0, }, currentFolder: null, moveFileCurrentFolder: null, diff --git a/src/app/features/files/store/files.selectors.ts b/src/app/features/files/store/files.selectors.ts index e33f8584f..43434bae2 100644 --- a/src/app/features/files/store/files.selectors.ts +++ b/src/app/features/files/store/files.selectors.ts @@ -33,6 +33,11 @@ export class FilesSelectors { return state.moveFileFiles.data; } + @Selector([FilesState]) + static getMoveFileFilesTotalCount(state: FilesStateModel): number { + return state.moveFileFiles.totalCount; + } + @Selector([FilesState]) static isMoveFileFilesLoading(state: FilesStateModel): boolean { return state.moveFileFiles.isLoading; diff --git a/src/app/features/files/store/files.state.ts b/src/app/features/files/store/files.state.ts index 0eeb7f82f..f02aabecc 100644 --- a/src/app/features/files/store/files.state.ts +++ b/src/app/features/files/store/files.state.ts @@ -49,7 +49,7 @@ export class FilesState { moveFileFiles: { ...state.moveFileFiles, isLoading: true, error: null }, }); - return this.filesService.getFiles(action.filesLink, '', '').pipe( + return this.filesService.getFiles(action.filesLink, '', '', action.page).pipe( tap({ next: (response) => { ctx.patchState({ @@ -57,6 +57,7 @@ export class FilesState { data: response.files, isLoading: false, error: null, + totalCount: response.meta?.total ?? 0, }, isAnonymous: response.meta?.anonymous ?? false, }); 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 9ed4e263c..a8f9ab451 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 @@ -41,6 +41,7 @@

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

[files]="files()" [totalCount]="filesTotalCount()" [currentFolder]="currentFolder()" + [storage]="currentRootFolder()" [isLoading]="isFilesLoading() || isStorageLoading" [actions]="filesTreeActions" [resourceId]="this.selectedRoot!" 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 deb63a720..1f6730bdb 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 @@ -29,7 +29,7 @@ import { } from '@osf/features/files/store'; import { FilesTreeComponent, SelectComponent } from '@osf/shared/components'; import { Primitive } from '@osf/shared/helpers'; -import { ConfiguredStorageAddonModel, FilesTreeActions, OsfFile, SelectOption } from '@osf/shared/models'; +import { ConfiguredStorageAddonModel, FileLabelModel, FilesTreeActions, SelectOption } from '@osf/shared/models'; import { Project } from '@osf/shared/models/projects'; import { environment } from 'src/environments/environment'; @@ -58,9 +58,11 @@ export class FilesWidgetComponent { readonly configuredStorageAddons = select(FilesSelectors.getConfiguredStorageAddons); readonly isConfiguredStorageAddonsLoading = select(FilesSelectors.isConfiguredStorageAddonsLoading); - currentRootFolder = model<{ label: string; folder: OsfFile } | null>(null); + currentRootFolder = model(null); pageNumber = signal(1); + readonly osfStorageLabel = 'Osf Storage'; + readonly options = computed(() => { const components = this.components(); return [this.rootOption(), ...this.flatComponents(components)]; @@ -70,10 +72,16 @@ export class FilesWidgetComponent { const rootFolders = this.rootFolders(); const addons = this.configuredStorageAddons(); if (rootFolders && addons) { - return rootFolders.map((folder) => ({ - label: this.getAddonName(addons, folder.provider), - folder: folder, - })); + return rootFolders + .map((folder) => ({ + label: this.getAddonName(addons, folder.provider), + folder: folder, + })) + .sort((a, b) => { + if (a.label === this.osfStorageLabel) return -1; + if (b.label === this.osfStorageLabel) return 1; + return a.label.localeCompare(b.label); + }); } return []; }); @@ -118,7 +126,7 @@ export class FilesWidgetComponent { const osfRootFolder = rootFolders.find((folder) => folder.provider === 'osfstorage'); if (osfRootFolder) { this.currentRootFolder.set({ - label: 'Osf Storage', + label: this.osfStorageLabel, folder: osfRootFolder, }); } @@ -166,7 +174,7 @@ export class FilesWidgetComponent { private getAddonName(addons: ConfiguredStorageAddonModel[], provider: string): string { if (provider === 'osfstorage') { - return 'Osf Storage'; + return this.osfStorageLabel; } else { return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; } 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 13657fdf1..e86b7da08 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -43,7 +43,7 @@
} @else {
-
+
@if (file.kind !== 'folder') {
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 d8e8f65fb..cf06b5434 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -31,7 +31,7 @@ import { FileMenuType } from '@osf/shared/enums'; import { CustomPaginatorComponent, FileMenuComponent, LoadingSpinnerComponent } from '@shared/components'; import { StopPropagationDirective } from '@shared/directives'; import { hasViewOnlyParam } from '@shared/helpers'; -import { FileMenuAction, FilesTreeActions, OsfFile } from '@shared/models'; +import { FileLabelModel, FileMenuAction, FilesTreeActions, OsfFile } from '@shared/models'; import { FileSizePipe } from '@shared/pipes'; import { CustomConfirmationService, FilesService, ToastService } from '@shared/services'; @@ -69,6 +69,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { totalCount = input(0); isLoading = input(); currentFolder = input.required(); + storage = input.required(); resourceId = input.required(); actions = input.required(); viewOnly = input(true); @@ -84,17 +85,16 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { uploadFileConfirmed = output(); filesPageChange = output(); + foldersStack: OsfFile[] = []; itemsPerPage = 10; first = 0; - hasParentFolder = false; - folderStack: OsfFile[] = []; readonly FileMenuType = FileMenuType; readonly nodes = computed(() => { const currentFolder = this.currentFolder(); const files = this.files(); - const hasParent = this.folderStack.length > 0; + const hasParent = this.foldersStack.length > 0; if (hasParent) { return [ { @@ -174,6 +174,13 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { this.updateFilesList(currentFolder).subscribe(() => this.folderIsOpening.emit(false)); } }); + + effect(() => { + const storageChanged = this.storage(); + if (storageChanged) { + this.foldersStack = []; + } + }); } openEntry(file: OsfFile) { @@ -188,7 +195,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } else { const current = this.currentFolder(); if (current) { - this.folderStack.push(current); + this.foldersStack.push(current); } this.resetPagination(); this.actions().setFilesIsLoading?.(true); @@ -198,7 +205,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } openParentFolder() { - const previous = this.folderStack.pop(); + const previous = this.foldersStack.pop(); if (previous) { this.actions().setCurrentFolder(previous); } @@ -355,6 +362,8 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { file: file, resourceId: this.resourceId(), action: action, + storageName: this.storage()?.label, + foldersStack: [...this.foldersStack], }, }); }); diff --git a/src/app/shared/mocks/cedar-metadata-data-template-json-api.mock.ts b/src/app/shared/mocks/cedar-metadata-data-template-json-api.mock.ts index 7cf9ab6ba..755b01cc2 100644 --- a/src/app/shared/mocks/cedar-metadata-data-template-json-api.mock.ts +++ b/src/app/shared/mocks/cedar-metadata-data-template-json-api.mock.ts @@ -1,4 +1,4 @@ -import { CedarMetadataTemplate } from '@osf/features/project/metadata/models'; +import { CedarMetadataTemplate } from '@osf/features/metadata/models'; export const CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK: CedarMetadataTemplate = { id: 'template-1', diff --git a/src/app/shared/models/files/file-label.model.ts b/src/app/shared/models/files/file-label.model.ts new file mode 100644 index 000000000..314010399 --- /dev/null +++ b/src/app/shared/models/files/file-label.model.ts @@ -0,0 +1,6 @@ +import { OsfFile } from './file.model'; + +export interface FileLabelModel { + label: string; + folder: OsfFile; +} diff --git a/src/app/shared/models/files/index.ts b/src/app/shared/models/files/index.ts index 642857f30..3c795b9cf 100644 --- a/src/app/shared/models/files/index.ts +++ b/src/app/shared/models/files/index.ts @@ -1,4 +1,5 @@ export * from './file.model'; +export * from './file-label.model'; export * from './file-menu-action.model'; export * from './file-payload-json-api.model'; export * from './file-version.model'; From 6e91985b170650653c8e64d68a1c9fa687519155 Mon Sep 17 00:00:00 2001 From: nmykhalkevych-exoft Date: Fri, 5 Sep 2025 20:11:08 +0300 Subject: [PATCH 3/7] Update src/app/features/project/overview/components/files-widget/files-widget.component.html Co-authored-by: nsemets --- .../files-widget/files-widget.component.html | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 a8f9ab451..729fc757d 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 @@ -18,18 +18,14 @@

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

@for (option of storageAddons(); track option.folder.id) { - - - {{ option.label }} - } From 65bc52b984ceb1fcf759e2e7454cb9c771d72173 Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Fri, 5 Sep 2025 23:42:22 +0300 Subject: [PATCH 4/7] feat(file): resolve comments --- .../files/pages/files/files.component.html | 2 +- .../file-step/file-step.component.html | 2 ++ .../files-widget/files-widget.component.html | 3 ++- .../files-widget/files-widget.component.ts | 5 ++-- .../files-control.component.html | 2 ++ .../files-control/files-control.component.ts | 15 +++++------ .../new-registration.component.ts | 25 +++++++++++-------- .../registries/store/default.state.ts | 1 + .../store/handlers/files.handlers.ts | 1 + .../store/handlers/projects.handlers.ts | 6 ++--- .../registries/store/registries.actions.ts | 1 + .../registries/store/registries.model.ts | 2 +- .../registries/store/registries.selectors.ts | 5 ++++ .../registries/store/registries.state.ts | 4 +-- src/assets/i18n/en.json | 1 - 15 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 20dc629de..ee064c1cb 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -1,4 +1,4 @@ - + @if (!dataLoaded()) { 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 616d5e78b..767a637af 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 @@ -89,6 +89,8 @@

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

-

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

+

{{ 'navigation.files' | translate }}

@if (isStorageLoading) {
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 1f6730bdb..c88ef63bc 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 @@ -18,6 +18,7 @@ import { signal, } from '@angular/core'; +import { FileProvider } from '@osf/features/files/constants'; import { FilesSelectors, GetConfiguredStorageAddons, @@ -123,7 +124,7 @@ export class FilesWidgetComponent { effect(() => { const rootFolders = this.rootFolders(); if (rootFolders) { - const osfRootFolder = rootFolders.find((folder) => folder.provider === 'osfstorage'); + const osfRootFolder = rootFolders.find((folder) => folder.provider === FileProvider.OsfStorage); if (osfRootFolder) { this.currentRootFolder.set({ label: this.osfStorageLabel, @@ -173,7 +174,7 @@ export class FilesWidgetComponent { } private getAddonName(addons: ConfiguredStorageAddonModel[], provider: string): string { - if (provider === 'osfstorage') { + if (provider === FileProvider.OsfStorage) { return this.osfStorageLabel; } else { return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; 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 9cbcb880d..ea73def20 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 @@ -52,6 +52,8 @@ this.actions.setCurrentFolder(folder), setFilesIsLoading: (isLoading) => this.actions.setFilesIsLoading(isLoading), getFiles: (filesLink) => this.actions.getFiles(filesLink), diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index c36413b32..3dc94fdda 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -10,6 +10,7 @@ import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/cor import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@core/store/user'; import { SubHeaderComponent } from '@osf/shared/components'; import { ToastService } from '@osf/shared/services'; @@ -27,20 +28,21 @@ export class NewRegistrationComponent { private readonly toastService = inject(ToastService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - protected readonly projects = select(RegistriesSelectors.getProjects); - protected readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); - protected readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); - protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); - protected readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); - protected readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); - protected actions = createDispatchMap({ + readonly projects = select(RegistriesSelectors.getProjects); + readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); + readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); + readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); + readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); + readonly user = select(UserSelectors.getCurrentUser); + actions = createDispatchMap({ getProjects: GetProjects, getProviderSchemas: GetProviderSchemas, createDraft: CreateDraft, }); - protected readonly providerId = this.route.snapshot.params['providerId']; - protected readonly projectId = this.route.snapshot.queryParams['projectId']; + readonly providerId = this.route.snapshot.params['providerId']; + readonly projectId = this.route.snapshot.queryParams['projectId']; fromProject = this.projectId !== undefined; @@ -50,7 +52,10 @@ export class NewRegistrationComponent { }); constructor() { - this.actions.getProjects(); + const userId = this.user()?.id; + if (userId) { + this.actions.getProjects(userId); + } this.actions.getProviderSchemas(this.providerId); effect(() => { const providerSchema = this.draftForm.get('providerSchema')?.value; diff --git a/src/app/features/registries/store/default.state.ts b/src/app/features/registries/store/default.state.ts index 73eb1596b..d2a0e0c42 100644 --- a/src/app/features/registries/store/default.state.ts +++ b/src/app/features/registries/store/default.state.ts @@ -55,6 +55,7 @@ export const DefaultState: RegistriesStateModel = { data: [], isLoading: false, error: null, + totalCount: 0, }, currentFolder: null, moveFileCurrentFolder: null, diff --git a/src/app/features/registries/store/handlers/files.handlers.ts b/src/app/features/registries/store/handlers/files.handlers.ts index 825a4b98f..aa2e1a7a6 100644 --- a/src/app/features/registries/store/handlers/files.handlers.ts +++ b/src/app/features/registries/store/handlers/files.handlers.ts @@ -50,6 +50,7 @@ export class FilesHandlers { data: response, isLoading: false, error: null, + totalCount: response.length, }, }); }), diff --git a/src/app/features/registries/store/handlers/projects.handlers.ts b/src/app/features/registries/store/handlers/projects.handlers.ts index 75a405a65..ba013df93 100644 --- a/src/app/features/registries/store/handlers/projects.handlers.ts +++ b/src/app/features/registries/store/handlers/projects.handlers.ts @@ -12,7 +12,8 @@ import { RegistriesStateModel } from '../registries.model'; export class ProjectsHandlers { projectsService = inject(ProjectsService); - getProjects({ patchState }: StateContext) { + getProjects({ patchState }: StateContext, userId: string) { + // [NM] TODO: move this parameter to projects.service const params: Record = { 'filter[current_user_permissions]': 'admin', }; @@ -23,8 +24,7 @@ export class ProjectsHandlers { isLoading: true, }, }); - // [NM] TODO: check if need to change 'me' to user id - return this.projectsService.fetchProjects('me', params).subscribe({ + return this.projectsService.fetchProjects(userId, params).subscribe({ next: (projects: Project[]) => { patchState({ projects: { diff --git a/src/app/features/registries/store/registries.actions.ts b/src/app/features/registries/store/registries.actions.ts index 019a79765..bf5bf1e7a 100644 --- a/src/app/features/registries/store/registries.actions.ts +++ b/src/app/features/registries/store/registries.actions.ts @@ -18,6 +18,7 @@ export class GetProviderSchemas { export class GetProjects { static readonly type = '[Registries] Get Projects'; + constructor(public userId: string) {} } export class CreateDraft { diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index 582df3dc7..fbcb38242 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -24,7 +24,7 @@ export interface RegistriesStateModel { stepsValidation: Record; draftRegistrations: AsyncStateWithTotalCount; submittedRegistrations: AsyncStateWithTotalCount; - files: AsyncStateModel; + files: AsyncStateWithTotalCount; currentFolder: OsfFile | null; moveFileCurrentFolder: OsfFile | null; rootFolders: AsyncStateModel; diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 346d8cea4..ba24b69d2 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -147,6 +147,11 @@ export class RegistriesSelectors { return state.files.data; } + @Selector([RegistriesState]) + static getFilesTotalCount(state: RegistriesStateModel): number { + return state.files.totalCount; + } + @Selector([RegistriesState]) static isFilesLoading(state: RegistriesStateModel): boolean { return state.files.isLoading; diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index 701197a89..eeb5f0982 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -94,8 +94,8 @@ export class RegistriesState { } @Action(GetProjects) - getProjects(ctx: StateContext) { - return this.projectsHandler.getProjects(ctx); + getProjects(ctx: StateContext, { userId }: GetProjects) { + return this.projectsHandler.getProjects(ctx, userId); } @Action(FetchProjectChildren) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 0723697e9..a6e08287f 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -946,7 +946,6 @@ } }, "files": { - "title": "Files", "storageLocation": "OSF Storage", "searchPlaceholder": "Search your projects", "sort": { From 85e58dc20a1c2c1c4052b934fbac375a8c8ee4b9 Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Mon, 8 Sep 2025 10:43:40 +0300 Subject: [PATCH 5/7] feat(file): refactoring --- .../constants/file-provider.constants.ts | 2 +- .../files/pages/files/files.component.ts | 8 +++-- src/app/features/files/store/files.actions.ts | 6 ++++ src/app/features/files/store/files.state.ts | 6 ++++ .../files-widget/files-widget.component.ts | 35 ++++++++++++++++--- .../overview/project-overview.component.html | 4 +-- .../overview/project-overview.component.ts | 11 +++--- .../overview/store/project-overview.model.ts | 8 ----- .../store/project-overview.selectors.ts | 10 ------ .../overview/store/project-overview.state.ts | 25 ------------- 10 files changed, 58 insertions(+), 57 deletions(-) diff --git a/src/app/features/files/constants/file-provider.constants.ts b/src/app/features/files/constants/file-provider.constants.ts index 01df352e3..f494f731a 100644 --- a/src/app/features/files/constants/file-provider.constants.ts +++ b/src/app/features/files/constants/file-provider.constants.ts @@ -1,6 +1,6 @@ export const FileProvider = { OsfStorage: 'osfstorage', - GoogleDrive: 'google-drive', + GoogleDrive: 'googledrive', Box: 'box', DropBox: 'dropbox', OneDrive: 'onedrive', diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index a413f2787..67eb41cc7 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -38,6 +38,7 @@ import { RenameEntry, ResetState, SetCurrentFolder, + SetCurrentProvider, SetFilesIsLoading, SetMoveFileCurrentFolder, SetSearch, @@ -117,6 +118,7 @@ export class FilesComponent { setSort: SetSort, getRootFolders: GetRootFolders, getConfiguredStorageAddons: GetConfiguredStorageAddons, + setCurrentProvider: SetCurrentProvider, resetState: ResetState, }); @@ -228,10 +230,12 @@ export class FilesComponent { effect(() => { const currentRootFolder = this.currentRootFolder(); if (currentRootFolder) { - this.isGoogleDrive.set(currentRootFolder.folder.provider === 'googledrive'); + const provider = currentRootFolder.folder?.provider; + this.isGoogleDrive.set(provider === FileProvider.GoogleDrive); if (this.isGoogleDrive()) { this.setGoogleAccountId(); } + this.actions.setCurrentProvider(provider ?? FileProvider.OsfStorage); this.actions.setCurrentFolder(currentRootFolder.folder); } }); @@ -399,7 +403,7 @@ export class FilesComponent { private setGoogleAccountId(): void { const addons = this.configuredStorageAddons(); - const googleDrive = addons?.find((addon) => addon.externalServiceName === 'googledrive'); + const googleDrive = addons?.find((addon) => addon.externalServiceName === FileProvider.GoogleDrive); if (googleDrive) { this.accountId.set(googleDrive.baseAccountId); this.selectedRootFolder.set({ diff --git a/src/app/features/files/store/files.actions.ts b/src/app/features/files/store/files.actions.ts index 32138b227..1f8f55de7 100644 --- a/src/app/features/files/store/files.actions.ts +++ b/src/app/features/files/store/files.actions.ts @@ -66,6 +66,12 @@ export class GetMoveFileFiles { ) {} } +export class SetCurrentProvider { + static readonly type = '[Files] Set Current Provider'; + + constructor(public provider: string) {} +} + export class GetFile { static readonly type = '[Files] Get File'; diff --git a/src/app/features/files/store/files.state.ts b/src/app/features/files/store/files.state.ts index f02aabecc..dd97d10de 100644 --- a/src/app/features/files/store/files.state.ts +++ b/src/app/features/files/store/files.state.ts @@ -24,6 +24,7 @@ import { RenameEntry, ResetState, SetCurrentFolder, + SetCurrentProvider, SetFileMetadata, SetFilesIsLoading, SetMoveFileCurrentFolder, @@ -160,6 +161,11 @@ export class FilesState { ctx.patchState({ sort: action.sort }); } + @Action(SetCurrentProvider) + setCurrentProvider(ctx: StateContext, action: SetCurrentProvider) { + ctx.patchState({ provider: action.provider }); + } + @Action(GetFile) getFile(ctx: StateContext, action: GetFile) { const state = ctx.getState(); 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 c88ef63bc..0960398b3 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 @@ -30,7 +30,13 @@ import { } from '@osf/features/files/store'; import { FilesTreeComponent, SelectComponent } from '@osf/shared/components'; import { Primitive } from '@osf/shared/helpers'; -import { ConfiguredStorageAddonModel, FileLabelModel, FilesTreeActions, SelectOption } from '@osf/shared/models'; +import { + ConfiguredStorageAddonModel, + FileLabelModel, + FilesTreeActions, + NodeShortInfoModel, + SelectOption, +} from '@osf/shared/models'; import { Project } from '@osf/shared/models/projects'; import { environment } from 'src/environments/environment'; @@ -44,7 +50,7 @@ import { environment } from 'src/environments/environment'; }) export class FilesWidgetComponent { rootOption = input.required(); - components = input.required[]>(); + components = input.required(); areComponentsLoading = input(false); private readonly destroyRef = inject(DestroyRef); @@ -65,8 +71,8 @@ export class FilesWidgetComponent { readonly osfStorageLabel = 'Osf Storage'; readonly options = computed(() => { - const components = this.components(); - return [this.rootOption(), ...this.flatComponents(components)]; + const components = this.components().filter((component) => this.rootOption().value !== component.id); + return [this.rootOption(), ...this.buildOptions(components).reverse()]; }); readonly storageAddons = computed(() => { @@ -173,6 +179,27 @@ export class FilesWidgetComponent { }); } + private buildOptions(nodes: NodeShortInfoModel[] = [], parentPath = '..'): SelectOption[] { + return nodes.reduce((acc, node) => { + const pathParts: string[] = []; + + let current: NodeShortInfoModel | undefined = node; + while (current) { + pathParts.unshift(current.title ?? ''); + current = nodes.find((n) => n.id === current?.parentId); + } + + const fullPath = parentPath ? `${parentPath}/${pathParts.join('/')}` : pathParts.join('/'); + + acc.push({ + value: node.id, + label: fullPath, + }); + + return acc; + }, []); + } + private getAddonName(addons: ConfiguredStorageAddonModel[], provider: string): string { if (provider === FileProvider.OsfStorage) { return this.osfStorageLabel; diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index d187662d1..9bd5c4508 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -80,8 +80,8 @@ } diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 3bac4f06f..804aa7f4d 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -39,11 +39,13 @@ import { ClearCollections, ClearWiki, CollectionsSelectors, + CurrentResourceSelectors, GetBookmarksCollectionId, GetCollectionProvider, GetConfiguredStorageAddons, GetHomeWiki, GetLinkedResources, + GetResourceWithChildren, } from '@osf/shared/stores'; import { GetActivityLogs } from '@osf/shared/stores/activity-logs'; import { @@ -66,7 +68,6 @@ import { import { ClearProjectOverview, GetComponents, - GetComponentsTree, GetProjectById, ProjectOverviewSelectors, SetProjectCustomCitation, @@ -116,8 +117,8 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement isProjectLoading = select(ProjectOverviewSelectors.getProjectLoading); isCollectionProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); isReviewActionsLoading = select(CollectionsModerationSelectors.getCurrentReviewActionLoading); - componentsTree = select(ProjectOverviewSelectors.getComponentsTree); - areComponentsTreeLoading = select(ProjectOverviewSelectors.getComponentsTreeLoading); + components = select(CurrentResourceSelectors.getResourceWithChildren); + areComponentsLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); readonly activityPageSize = 5; readonly activityDefaultPage = 1; @@ -137,7 +138,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement clearWiki: ClearWiki, clearCollections: ClearCollections, clearCollectionModeration: ClearCollectionModeration, - getComponentsTree: GetComponentsTree, + getComponentsTree: GetResourceWithChildren, getRootFolders: GetRootFolders, getConfiguredStorageAddons: GetConfiguredStorageAddons, }); @@ -243,7 +244,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement this.actions.getBookmarksId(); this.actions.getHomeWiki(ResourceType.Project, projectId); this.actions.getComponents(projectId); - this.actions.getComponentsTree(projectId); + this.actions.getComponentsTree(projectId, ResourceType.Project); this.actions.getLinkedProjects(projectId); this.actions.getActivityLogs(projectId, this.activityDefaultPage.toString(), this.activityPageSize.toString()); this.setupDataciteViewTrackerEffect().subscribe(); diff --git a/src/app/features/project/overview/store/project-overview.model.ts b/src/app/features/project/overview/store/project-overview.model.ts index e58ea8665..8be6cd60b 100644 --- a/src/app/features/project/overview/store/project-overview.model.ts +++ b/src/app/features/project/overview/store/project-overview.model.ts @@ -1,12 +1,10 @@ import { AsyncStateModel, ComponentOverview } from '@osf/shared/models'; -import { Project } from '@osf/shared/models/projects'; import { ProjectOverview } from '../models'; export interface ProjectOverviewStateModel { project: AsyncStateModel; components: AsyncStateModel; - componentsTree: AsyncStateModel; isAnonymous: boolean; } @@ -23,11 +21,5 @@ export const PROJECT_OVERVIEW_DEFAULTS: ProjectOverviewStateModel = { isSubmitting: false, error: null, }, - componentsTree: { - data: [], - isLoading: false, - isSubmitting: false, - error: null, - }, isAnonymous: false, }; diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index 7327857e0..ef5403262 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -29,16 +29,6 @@ export class ProjectOverviewSelectors { return state.components.isSubmitting; } - @Selector([ProjectOverviewState]) - static getComponentsTree(state: ProjectOverviewStateModel) { - return state.componentsTree.data; - } - - @Selector([ProjectOverviewState]) - static getComponentsTreeLoading(state: ProjectOverviewStateModel) { - return state.componentsTree.isLoading; - } - @Selector([ProjectOverviewState]) static getForkProjectSubmitting(state: ProjectOverviewStateModel) { return state.project.isSubmitting; diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index f2025982a..fb9a3d4d7 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -17,7 +17,6 @@ import { DuplicateProject, ForkResource, GetComponents, - GetComponentsTree, GetProjectById, SetProjectCustomCitation, UpdateProjectPublicStatus, @@ -247,28 +246,4 @@ export class ProjectOverviewState { catchError((error) => handleSectionError(ctx, 'components', error)) ); } - - @Action(GetComponentsTree) - getComponentsTree(ctx: StateContext, action: GetComponentsTree) { - const state = ctx.getState(); - ctx.patchState({ - componentsTree: { - ...state.componentsTree, - isLoading: true, - }, - }); - - return this.projectsService.getComponentsTree(action.projectId).pipe( - tap((components) => { - ctx.patchState({ - componentsTree: { - data: components, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'componentsTree', error)) - ); - } } From 01da4167509d9bb7a7b159dc5833f84d40b24bc4 Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Mon, 8 Sep 2025 14:00:36 +0300 Subject: [PATCH 6/7] feat(file): remove sorting storage --- .../files-widget/files-widget.component.html | 2 +- .../files-widget/files-widget.component.ts | 14 ++++---------- 2 files changed, 5 insertions(+), 11 deletions(-) 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 b491fd7f2..f354459d2 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 @@ -19,7 +19,7 @@

{{ 'navigation.files' | translate }}

@for (option of storageAddons(); track option.folder.id) { - ({ - label: this.getAddonName(addons, folder.provider), - folder: folder, - })) - .sort((a, b) => { - if (a.label === this.osfStorageLabel) return -1; - if (b.label === this.osfStorageLabel) return 1; - return a.label.localeCompare(b.label); - }); + return rootFolders.map((folder) => ({ + label: this.getAddonName(addons, folder.provider), + folder: folder, + })); } return []; }); From 6441fc43e0ca56d98c963ee340507b8e7a842758 Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Mon, 8 Sep 2025 14:26:19 +0300 Subject: [PATCH 7/7] feat(file): navigate to file --- .../components/files-widget/files-widget.component.html | 1 + .../components/files-widget/files-widget.component.ts | 8 ++++++++ 2 files changed, 9 insertions(+) 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 f354459d2..6d519855e 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 @@ -43,6 +43,7 @@

{{ 'navigation.files' | translate }}

[actions]="filesTreeActions" [resourceId]="this.selectedRoot!" [provider]="provider()" + (entryFileClicked)="navigateToFile($event)" (filesPageChange)="onFilesPageChange($event)" >
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 3d622d847..8a0afd8ab 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 @@ -17,6 +17,7 @@ import { model, signal, } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; import { FileProvider } from '@osf/features/files/constants'; import { @@ -35,6 +36,7 @@ import { FileLabelModel, FilesTreeActions, NodeShortInfoModel, + OsfFile, SelectOption, } from '@osf/shared/models'; import { Project } from '@osf/shared/models/projects'; @@ -52,6 +54,8 @@ export class FilesWidgetComponent { rootOption = input.required(); components = input.required(); areComponentsLoading = input(false); + router = inject(Router); + activeRoute = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); @@ -213,6 +217,10 @@ export class FilesWidgetComponent { } } + navigateToFile(file: OsfFile) { + this.router.navigate(['files', file.guid], { relativeTo: this.activeRoute.parent }); + } + onFilesPageChange(page: number) { this.pageNumber.set(page); }