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/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.html b/src/app/features/files/pages/files/files.component.html index db54afe8a..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()) { @@ -24,7 +24,7 @@

{{ option.label }}

- +
@@ -124,7 +124,9 @@
diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index ed1a6e839..67eb41cc7 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, Observable, skip, switchMap, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; import { @@ -38,11 +38,16 @@ import { RenameEntry, ResetState, SetCurrentFolder, + SetCurrentProvider, SetFilesIsLoading, SetMoveFileCurrentFolder, SetSearch, SetSort, } from '@osf/features/files/store'; +import { GoogleFilePickerComponent } from '@osf/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component'; +import { ALL_SORT_OPTIONS } from '@osf/shared/constants'; +import { ResourceType } from '@osf/shared/enums'; +import { hasViewOnlyParam, IS_MEDIUM } from '@osf/shared/helpers'; import { FilesTreeComponent, FormSelectComponent, @@ -50,12 +55,14 @@ import { SearchInputComponent, SubHeaderComponent, ViewOnlyLinkMessageComponent, -} from '@osf/shared/components'; -import { GoogleFilePickerComponent } from '@osf/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component'; -import { ALL_SORT_OPTIONS } from '@osf/shared/constants'; -import { ResourceType } from '@osf/shared/enums'; -import { hasViewOnlyParam, IS_MEDIUM } from '@osf/shared/helpers'; -import { ConfiguredStorageAddonModel, FilesTreeActions, OsfFile, StorageItemModel } from '@shared/models'; +} from '@shared/components'; +import { + ConfiguredStorageAddonModel, + FileLabelModel, + FilesTreeActions, + OsfFile, + StorageItemModel, +} from '@shared/models'; import { FilesService } from '@shared/services'; import { CreateFolderDialogComponent, FileBrowserInfoComponent } from '../../components'; @@ -111,6 +118,7 @@ export class FilesComponent { setSort: SetSort, getRootFolders: GetRootFolders, getConfiguredStorageAddons: GetConfiguredStorageAddons, + setCurrentProvider: SetCurrentProvider, resetState: ResetState, }); @@ -120,6 +128,7 @@ export class FilesComponent { return hasViewOnlyParam(this.router); }); 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); @@ -139,7 +148,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); @@ -147,6 +156,7 @@ export class FilesComponent { sortOptions = ALL_SORT_OPTIONS; storageProvider = FileProvider.OsfStorage; + pageNumber = signal(1); private readonly urlMap = new Map([ [ResourceType.Project, 'nodes'], @@ -180,7 +190,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), @@ -220,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); } }); @@ -235,7 +247,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()) { @@ -284,15 +296,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(); - // } - // } - // } }); } @@ -319,18 +322,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 { @@ -394,9 +397,13 @@ export class FilesComponent { } } + onFilesPageChange(page: number) { + this.pageNumber.set(page); + } + 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 a7ac92dc4..1f8f55de7 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 { @@ -57,7 +60,16 @@ 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 SetCurrentProvider { + static readonly type = '[Files] Set Current Provider'; + + constructor(public provider: string) {} } export class GetFile { diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index 1d21983ff..3cb6baac1 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -1,13 +1,13 @@ 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; - moveFileFiles: AsyncStateModel; + files: AsyncStateWithTotalCount; + moveFileFiles: AsyncStateWithTotalCount; currentFolder: OsfFile | null; moveFileCurrentFolder: OsfFile | null; search: string; @@ -29,11 +29,13 @@ export const filesStateDefaults: FilesStateModel = { data: [], isLoading: false, error: null, + totalCount: 0, }, moveFileFiles: { 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 b7f8b8f47..43434bae2 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; @@ -28,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 32074818d..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, @@ -49,7 +50,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 +58,7 @@ export class FilesState { data: response.files, isLoading: false, error: null, + totalCount: response.meta?.total ?? 0, }, isAnonymous: response.meta?.anonymous ?? false, }); @@ -69,8 +71,8 @@ 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: (response) => { ctx.patchState({ @@ -78,6 +80,7 @@ export class FilesState { data: response.files, isLoading: false, error: null, + totalCount: response.meta?.total ?? 0, }, isAnonymous: response.meta?.anonymous ?? false, }); @@ -158,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/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 }}

+

{{ 'navigation.files' | translate }}

+ + @if (isStorageLoading) { +
+ + + +
+ } @else { + + + @for (option of storageAddons(); track option.folder.id) { + + + + + } + + + } + + + + 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..8a0afd8ab --- /dev/null +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.ts @@ -0,0 +1,227 @@ +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 { ActivatedRoute, Router } from '@angular/router'; + +import { FileProvider } from '@osf/features/files/constants'; +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, + FileLabelModel, + FilesTreeActions, + NodeShortInfoModel, + 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); + router = inject(Router); + activeRoute = inject(ActivatedRoute); + + 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(null); + pageNumber = signal(1); + + readonly osfStorageLabel = 'Osf Storage'; + + readonly options = computed(() => { + const components = this.components().filter((component) => this.rootOption().value !== component.id); + return [this.rootOption(), ...this.buildOptions(components).reverse()]; + }); + + 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 === FileProvider.OsfStorage); + if (osfRootFolder) { + this.currentRootFolder.set({ + label: this.osfStorageLabel, + 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 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; + } 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); + } + } + + navigateToFile(file: OsfFile) { + this.router.navigate(['files', file.guid], { relativeTo: this.activeRoute.parent }); + } + + 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 079045d2b..9bd5c4508 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -78,6 +78,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..fbe6efa4b 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 "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 815571b96..804aa7f4d 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 { ClearCollectionModeration, @@ -38,10 +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 { @@ -54,6 +58,7 @@ import { } from '@shared/components'; import { + FilesWidgetComponent, LinkedResourcesComponent, OverviewComponentsComponent, OverviewToolbarComponent, @@ -88,6 +93,7 @@ import { TranslatePipe, Message, RouterLink, + FilesWidgetComponent, ViewOnlyLinkMessageComponent, ViewOnlyLinkMessageComponent, ], @@ -111,6 +117,9 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement isProjectLoading = select(ProjectOverviewSelectors.getProjectLoading); isCollectionProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); isReviewActionsLoading = select(CollectionsModerationSelectors.getCurrentReviewActionLoading); + components = select(CurrentResourceSelectors.getResourceWithChildren); + areComponentsLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); + readonly activityPageSize = 5; readonly activityDefaultPage = 1; readonly SubmissionReviewStatus = SubmissionReviewStatus; @@ -129,6 +138,9 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement clearWiki: ClearWiki, clearCollections: ClearCollections, clearCollectionModeration: ClearCollectionModeration, + getComponentsTree: GetResourceWithChildren, + getRootFolders: GetRootFolders, + getConfiguredStorageAddons: GetConfiguredStorageAddons, }); readonly isCollectionsRoute = computed(() => { @@ -154,8 +166,8 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement }); currentProject = select(ProjectOverviewSelectors.getProject); - isAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); private currentProject$ = toObservable(this.currentProject); + isAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); userPermissions = computed(() => { return this.currentProject()?.currentUserPermissions || []; @@ -201,12 +213,12 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement return null; }); - 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(); @@ -214,7 +226,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); } @@ -225,6 +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, 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.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.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index 5dbf1fc37..fb9a3d4d7 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -5,6 +5,7 @@ import { catchError, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers'; +import { ProjectsService } from '@osf/shared/services/projects.service'; import { ResourceType } from '@shared/enums'; import { ProjectOverviewService } from '../services'; @@ -29,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) { 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/metadata/contributors/contributors.component.ts b/src/app/features/registries/components/metadata/contributors/contributors.component.ts index 267636654..81bb557c7 100644 --- a/src/app/features/registries/components/metadata/contributors/contributors.component.ts +++ b/src/app/features/registries/components/metadata/contributors/contributors.component.ts @@ -85,7 +85,6 @@ export class ContributorsComponent implements OnInit { onFocusOut() { // [NM] TODO: make request to update contributor if changed - console.log('Focus out event:', 'Changed:', this.hasChanges); if (this.control()) { this.control().markAsTouched(); this.control().markAsDirty(); 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/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/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 75fce07c9..ba013df93 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'; @@ -11,14 +12,19 @@ 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', + }; + patchState({ projects: { ...DefaultState.projects, isLoading: true, }, }); - return this.projectsService.getProjects().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/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index a3b2d97e9..e86b7da08 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -1,5 +1,5 @@
- @if (!viewOnly() && !hasViewOnly) { + @if (!hasViewOnly()) {
} @else {
-
+
@if (file.kind !== 'folder') {
@@ -95,15 +95,22 @@ } - + @if (totalCount() > itemsPerPage) { + + } @if (!files().length) { -
- @if (viewOnly() || hasViewOnly()) { -

{{ 'files.emptyState' | translate }}

+
+ @if (hasViewOnly()) { +

{{ '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 e5a04602a..bff92d66a 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 2c8052070..5fbf8c777 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 { @@ -30,10 +30,11 @@ import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; import { FileMenuType } from '@osf/shared/enums'; 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'; +import { CustomPaginatorComponent } from '../custom-paginator/custom-paginator.component'; import { FileMenuComponent } from '../file-menu/file-menu.component'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; @@ -50,6 +51,7 @@ import { environment } from 'src/environments/environment'; LoadingSpinnerComponent, FileMenuComponent, StopPropagationDirective, + CustomPaginatorComponent, ], templateUrl: './files-tree.component.html', styleUrl: './files-tree.component.scss', @@ -67,8 +69,10 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { readonly translateService = inject(TranslateService); files = input.required(); + totalCount = input(0); isLoading = input(); currentFolder = input.required(); + storage = input.required(); resourceId = input.required(); actions = input.required(); viewOnly = input(true); @@ -76,26 +80,34 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { provider = input(); isDragOver = signal(false); hasViewOnly = computed(() => { - return hasViewOnlyParam(this.router); + return hasViewOnlyParam(this.router) || this.viewOnly(); }); entryFileClicked = output(); folderIsOpening = output(); uploadFileConfirmed = output(); + filesPageChange = output(); + + foldersStack: OsfFile[] = []; + itemsPerPage = 10; + first = 0; readonly FileMenuType = FileMenuType; readonly nodes = computed(() => { - if (this.currentFolder()?.relationships?.parentFolderLink) { + const currentFolder = this.currentFolder(); + const files = this.files(); + const hasParent = this.foldersStack.length > 0; + if (hasParent) { return [ { - ...this.currentFolder(), - previousFolder: true, + ...currentFolder, + previousFolder: hasParent, }, - ...this.files(), + ...files, ] as OsfFile[]; } else { - return this.files(); + return [...files]; } }); @@ -161,9 +173,15 @@ 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)); + } + }); + + effect(() => { + const storageChanged = this.storage(); + if (storageChanged) { + this.foldersStack = []; } }); } @@ -178,35 +196,22 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { }); } } else { + const current = this.currentFolder(); + if (current) { + this.foldersStack.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.foldersStack.pop(); + if (previous) { + this.actions().setCurrentFolder(previous); + } } onFileMenuAction(action: FileMenuAction, file: OsfFile): void { @@ -360,15 +365,15 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { file: file, resourceId: this.resourceId(), action: action, + storageName: this.storage()?.label, + foldersStack: [...this.foldersStack], }, }); }); } - 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; @@ -453,4 +458,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/mappers/registration/page-schema.mapper.ts b/src/app/shared/mappers/registration/page-schema.mapper.ts index a9ba2c0ef..01fc419f3 100644 --- a/src/app/shared/mappers/registration/page-schema.mapper.ts +++ b/src/app/shared/mappers/registration/page-schema.mapper.ts @@ -138,7 +138,6 @@ export class PageSchemaMapper { } break; default: - console.warn(`Unexpected block type: ${item.attributes.block_type}`); return; } }); 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/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/files/index.ts b/src/app/shared/models/files/index.ts index d27ecbfe7..e02393083 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'; 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 7b337ce07..7336470ad 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 } from '../common'; +import { JsonApiResponse, MetaJsonApi, PaginationLinksJsonApi } from '../common'; import { LicenseRecordJsonApi } from '../licenses-json-api.model'; export interface ProjectJsonApi { @@ -17,6 +17,8 @@ export interface ProjectJsonApi { export interface ProjectsResponseJsonApi extends JsonApiResponse { data: ProjectJsonApi[]; + meta: MetaJsonApi; + links: PaginationLinksJsonApi; } export interface ProjectRelationshipsJsonApi { diff --git a/src/app/shared/models/view-only-links/view-only-link-response.model.ts b/src/app/shared/models/view-only-links/view-only-link-response.model.ts index 9a90111f7..15f1bb65c 100644 --- a/src/app/shared/models/view-only-links/view-only-link-response.model.ts +++ b/src/app/shared/models/view-only-links/view-only-link-response.model.ts @@ -1,6 +1,6 @@ import { MetaJsonApi } from '../common'; -import { UserDataJsonApi } from '../user'; import { BaseNodeDataJsonApi } from '../nodes'; +import { UserDataJsonApi } from '../user'; export interface ViewOnlyLinksResponseJsonApi { data: ViewOnlyLinkJsonApi[]; diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 57e01eb64..9c195db49 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -61,10 +61,12 @@ export class FilesService { getFiles( filesLink: string, search: string, - sort: string + sort: string, + page = 1 ): Observable<{ files: OsfFile[]; meta?: MetaAnonymousJsonApi }> { const params: Record = { sort: sort, + page: page.toString(), 'fields[files]': this.filesFields, 'filter[name]': search, }; 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 90ef1fbbc..a6e08287f 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -561,6 +561,9 @@ "title": "Wiki", "noWikiMessage": "Add important information, links, or images here to describe your project." }, + "files": { + "title": "Files" + }, "components": { "title": "Components", "addComponentButton": "Add Component", @@ -943,7 +946,6 @@ } }, "files": { - "title": "Files", "storageLocation": "OSF Storage", "searchPlaceholder": "Search your projects", "sort": { @@ -1018,7 +1020,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/styles/overrides/tree.scss b/src/styles/overrides/tree.scss index 60b70baff..8cb72c43a 100644 --- a/src/styles/overrides/tree.scss +++ b/src/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; + } + } }