From 03178094bec405a4168c40004700641e2dce2a2d Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Mon, 2 Jun 2025 18:15:11 +0300 Subject: [PATCH 1/2] chore(files): api --- src/app/app.routes.ts | 5 +- src/app/core/models/json-api.model.ts | 3 +- src/app/core/services/json-api.service.ts | 21 +- .../models/contributor-response.model.ts | 2 +- .../file-detail/file-detail.component.html | 263 +++++++++++-- .../file-detail/file-detail.component.ts | 155 +++++++- .../project/files/components/index.ts | 1 + .../move-file-dialog.component.html | 73 ++++ .../move-file-dialog.component.scss | 77 ++++ .../move-file-dialog.component.spec.ts} | 12 +- .../move-file-dialog.component.ts | 112 ++++++ .../mappers/file-custom-metadata.mapper.ts | 13 + .../project/files/mappers/files.mapper.ts | 49 +++ .../features/project/files/mappers/index.ts | 3 + .../files/mappers/project-metadata.mapper.ts | 26 ++ src/app/features/project/files/model/index.ts | 1 - .../files/model/project-files.models.ts | 50 --- .../models/data/file-menu-items.const.ts | 7 + .../files/models/data/file-provider.const.ts | 9 + .../files/models/data/files-sort.const.ts | 6 + .../features/project/files/models/index.ts | 24 ++ .../osf-models/file-custom-metafata.model.ts | 7 + .../file-project-contributor.model.ts | 5 + .../osf-models/file-system-entry.model.ts | 35 ++ .../models/osf-models/file-target.model.ts | 21 ++ .../project-custom-metadata.model.ts | 16 + .../osf-models/project-short-info.model.ts | 7 + .../requests/patch-file-metadata.mode.ts | 6 + .../responses/create-folder-response.model.ts | 5 + .../get-file-metadata-reponse.model.ts | 10 + .../get-file-target-response.model.ts | 39 ++ .../responses/get-files-response.model.ts | 65 ++++ ...get-project-contributors-response.model.ts | 14 + ...-project-custom-metadata-response.model.ts | 29 ++ .../get-project-short-info-response.model.ts | 16 + .../files/project-files.component.html | 351 +++++++++++++----- .../files/project-files.component.scss | 82 +++- .../project/files/project-files.component.ts | 330 ++++++++++++++-- .../files/services/project-files.service.ts | 203 ++++++++++ .../files/store/project-files.actions.ts | 117 ++++++ .../files/store/project-files.model.ts | 20 + .../files/store/project-files.selectors.ts | 62 ++++ .../files/store/project-files.state.ts | 338 +++++++++++++++++ .../raw-models/index-card-search.model.ts | 2 +- .../change-password.component.ts | 1 - .../mappers/account-settings.mapper.ts | 2 +- .../account-settings/mappers/emails.mapper.ts | 4 +- .../mappers/external-identities.mapper.ts | 2 +- .../mappers/regions.mapper.ts | 4 +- .../get-account-settings-response.model.ts | 2 +- .../responses/get-email-response.model.ts | 2 +- .../responses/get-regions-response.model.ts | 4 +- .../models/responses/list-emails.model.ts | 2 +- .../list-identities-response.model.ts | 2 +- .../services/account-settings.service.ts | 6 +- .../loading-spinner.component.scss | 2 +- .../sub-header/sub-header.component.html | 6 +- .../sub-header/sub-header.component.ts | 4 +- .../user-counts-response.model.ts | 3 +- .../services/filters-options.service.ts | 4 +- .../shared/utils/format-file-size.helper.ts | 11 + src/assets/i18n/en.json | 86 ++++- src/assets/styles/_base.scss | 6 + src/assets/styles/_common.scss | 5 + src/assets/styles/overrides/button.scss | 5 + src/assets/styles/overrides/spinner.scss | 7 + src/environments/environment.development.ts | 1 + src/environments/environment.ts | 1 + 68 files changed, 2618 insertions(+), 246 deletions(-) create mode 100644 src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html create mode 100644 src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss rename src/app/{shared/components/text-input/text-input.component.spec.ts => features/project/files/components/move-file-dialog/move-file-dialog.component.spec.ts} (50%) create mode 100644 src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts create mode 100644 src/app/features/project/files/mappers/file-custom-metadata.mapper.ts create mode 100644 src/app/features/project/files/mappers/files.mapper.ts create mode 100644 src/app/features/project/files/mappers/index.ts create mode 100644 src/app/features/project/files/mappers/project-metadata.mapper.ts delete mode 100644 src/app/features/project/files/model/index.ts delete mode 100644 src/app/features/project/files/model/project-files.models.ts create mode 100644 src/app/features/project/files/models/data/file-menu-items.const.ts create mode 100644 src/app/features/project/files/models/data/file-provider.const.ts create mode 100644 src/app/features/project/files/models/data/files-sort.const.ts create mode 100644 src/app/features/project/files/models/index.ts create mode 100644 src/app/features/project/files/models/osf-models/file-custom-metafata.model.ts create mode 100644 src/app/features/project/files/models/osf-models/file-project-contributor.model.ts create mode 100644 src/app/features/project/files/models/osf-models/file-system-entry.model.ts create mode 100644 src/app/features/project/files/models/osf-models/file-target.model.ts create mode 100644 src/app/features/project/files/models/osf-models/project-custom-metadata.model.ts create mode 100644 src/app/features/project/files/models/osf-models/project-short-info.model.ts create mode 100644 src/app/features/project/files/models/requests/patch-file-metadata.mode.ts create mode 100644 src/app/features/project/files/models/responses/create-folder-response.model.ts create mode 100644 src/app/features/project/files/models/responses/get-file-metadata-reponse.model.ts create mode 100644 src/app/features/project/files/models/responses/get-file-target-response.model.ts create mode 100644 src/app/features/project/files/models/responses/get-files-response.model.ts create mode 100644 src/app/features/project/files/models/responses/get-project-contributors-response.model.ts create mode 100644 src/app/features/project/files/models/responses/get-project-custom-metadata-response.model.ts create mode 100644 src/app/features/project/files/models/responses/get-project-short-info-response.model.ts create mode 100644 src/app/features/project/files/services/project-files.service.ts create mode 100644 src/app/features/project/files/store/project-files.actions.ts create mode 100644 src/app/features/project/files/store/project-files.model.ts create mode 100644 src/app/features/project/files/store/project-files.selectors.ts create mode 100644 src/app/features/project/files/store/project-files.state.ts create mode 100644 src/app/shared/utils/format-file-size.helper.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 8972a0c0c..f9f9b9801 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,6 +2,8 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; +import { ProjectFilesState } from '@osf/features/project/files/store/project-files.state'; + import { MyProfileResourceFiltersOptionsState } from './features/my-profile/components/filters/store'; import { MyProfileResourceFiltersState } from './features/my-profile/components/my-profile-resource-filters/store'; import { MyProfileState } from './features/my-profile/store'; @@ -117,9 +119,10 @@ export const routes: Routes = [ path: 'files', loadComponent: () => import('@osf/features/project/files/project-files.component').then((mod) => mod.ProjectFilesComponent), + providers: [provideStates([ProjectFilesState])], }, { - path: 'files/:fileId', + path: 'files/:fileGuid', loadComponent: () => import('@osf/features/project/files/components/file-detail/file-detail.component').then( (mod) => mod.FileDetailComponent diff --git a/src/app/core/models/json-api.model.ts b/src/app/core/models/json-api.model.ts index ef8c3b00a..83a81623b 100644 --- a/src/app/core/models/json-api.model.ts +++ b/src/app/core/models/json-api.model.ts @@ -12,10 +12,11 @@ export interface JsonApiResponseWithPaging extends JsonApiRespon }; } -export interface ApiData { +export interface ApiData { id: string; attributes: Attributes; embeds: Embeds; type: string; relationships: Relationships; + links: Links; } diff --git a/src/app/core/services/json-api.service.ts b/src/app/core/services/json-api.service.ts index 5b6221dea..496aff0f1 100644 --- a/src/app/core/services/json-api.service.ts +++ b/src/app/core/services/json-api.service.ts @@ -1,6 +1,6 @@ import { map, Observable } from 'rxjs'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpEvent, HttpParams } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { JsonApiResponse } from '@osf/core/models'; @@ -49,8 +49,23 @@ export class JsonApiService { return this.http.patch>(url, body).pipe(map((response) => response.data)); } - put(url: string, body: unknown): Observable { - return this.http.put>(url, body).pipe(map((response) => response.data)); + put(url: string, body: unknown, params?: Record): Observable { + return this.http + .put>(url, body, { params: this.buildHttpParams(params) }) + .pipe(map((response) => response.data)); + } + + putFile( + url: string, + file: File, + params?: Record + ): Observable>> { + return this.http.put>(url, file, { + params: params, + reportProgress: true, + observe: 'events', + responseType: 'json' as const, + }); } delete(url: string, body?: unknown): Observable { diff --git a/src/app/features/project/contributors/models/contributor-response.model.ts b/src/app/features/project/contributors/models/contributor-response.model.ts index 5c7487d70..e7e3b794d 100644 --- a/src/app/features/project/contributors/models/contributor-response.model.ts +++ b/src/app/features/project/contributors/models/contributor-response.model.ts @@ -1,7 +1,7 @@ import { ApiData } from '@osf/core/models'; import { Education, Employment } from '@osf/shared/models'; -export type ContributorResponse = ApiData; +export type ContributorResponse = ApiData; export interface ContributorAttributes { index: number; diff --git a/src/app/features/project/files/components/file-detail/file-detail.component.html b/src/app/features/project/files/components/file-detail/file-detail.component.html index 08aaef8d4..bbb3d7492 100644 --- a/src/app/features/project/files/components/file-detail/file-detail.component.html +++ b/src/app/features/project/files/components/file-detail/file-detail.component.html @@ -1,73 +1,262 @@ - +
- +
-
+ @if (safeLink) { + + } + @if (isIframeLoading) { +
+ +
+ } +
- + + +
+
+

{{ 'project.files.detail.fileMetadata.fields.title' | translate }}

+ +
+
+

{{ 'project.files.detail.fileMetadata.fields.description' | translate }}

+ +
+
+

{{ 'project.files.detail.fileMetadata.fields.resourceType' | translate }}

+ +
+
+

{{ 'project.files.detail.fileMetadata.fields.resourceLanguage' | translate }}

+ +
+ +
+ + +
+
+
diff --git a/src/app/features/project/files/components/file-detail/file-detail.component.ts b/src/app/features/project/files/components/file-detail/file-detail.component.ts index b780eab7c..63dfafbd1 100644 --- a/src/app/features/project/files/components/file-detail/file-detail.component.ts +++ b/src/app/features/project/files/components/file-detail/file-detail.component.ts @@ -1,17 +1,164 @@ +import { select, Store } from '@ngxs/store'; + +import { TranslateModule } from '@ngx-translate/core'; + import { Button } from 'primeng/button'; +import { Dialog } from 'primeng/dialog'; +import { InputText } from 'primeng/inputtext'; +import { Select } from 'primeng/select'; +import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; -import { RouterLink } from '@angular/router'; +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, DestroyRef, HostBinding, inject } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { SubHeaderComponent } from '@shared/components'; +import { + GetFileMetadata, + GetFileProjectContributors, + GetFileProjectMetadata, + GetFileTarget, + SetFileMetadata, +} from '@osf/features/project/files/store/project-files.actions'; +import { ProjectFilesSelectors } from '@osf/features/project/files/store/project-files.selectors'; +import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; @Component({ selector: 'osf-file-detail', - imports: [SubHeaderComponent, RouterLink, Button], + imports: [ + SubHeaderComponent, + RouterLink, + Button, + LoadingSpinnerComponent, + DatePipe, + Dialog, + InputText, + Select, + FormsModule, + ReactiveFormsModule, + Skeleton, + TranslateModule, + ], templateUrl: './file-detail.component.html', styleUrl: './file-detail.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileDetailComponent { @HostBinding('class') classes = 'flex flex-column flex-1 gap-4 w-full h-full'; + readonly store = inject(Store); + readonly router = inject(Router); + readonly route = inject(ActivatedRoute); + readonly destroyRef = inject(DestroyRef); + readonly sanitizer = inject(DomSanitizer); + readonly fb = inject(FormBuilder); + + file = select(ProjectFilesSelectors.getOpenedFile); + fileMetadata = select(ProjectFilesSelectors.getFileCustomMetadata); + projectMetadata = select(ProjectFilesSelectors.getProjectMetadata); + contributors = select(ProjectFilesSelectors.getProjectContributors); + safeLink: SafeResourceUrl | null = null; + + isIframeLoading = true; + editFileMetadataVisible = false; + + fileMetadataForm = new FormGroup({ + title: new FormControl(null), + description: new FormControl(null), + resourceType: new FormControl(null), + resourceLanguage: new FormControl(null), + }); + + // TO DO: figure out where to get this options + resourceTypes = [ + { value: 'Audiovisual' }, + { value: 'Book' }, + { value: 'BookChapter' }, + { value: 'Collection' }, + { value: 'ComputationalNotebook' }, + { value: 'ConferencePaper' }, + { value: 'ConferenceProceeding' }, + { value: 'DataPaper' }, + { value: 'Dataset' }, + { value: 'Dissertation' }, + { value: 'Event' }, + { value: 'Image' }, + { value: 'Instrument' }, + { value: 'InteractiveResource' }, + { value: 'Journal' }, + { value: 'JournalArticle' }, + { value: 'Model' }, + { value: 'OutputManagementPlan' }, + { value: 'PeerReview' }, + { value: 'PhysicalObject' }, + { value: 'Preprint' }, + { value: 'Report' }, + { value: 'Service' }, + { value: 'Software' }, + { value: 'Standard' }, + { value: 'StudyRegistration' }, + { value: 'Text' }, + { value: 'Workflow' }, + { value: 'Other' }, + ]; + + // TO DO: figure out where to get this options + languages = [ + { value: 'eng', label: 'English' }, + { value: 'ukr', label: 'Ukrainian' }, + ]; + + constructor() { + // Subscribe to route parameter changes + this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + const guid = params['fileGuid']; + this.store.dispatch(new GetFileTarget(guid)).subscribe(() => { + const link = this.file().data?.links.render; + if (link) { + this.safeLink = this.sanitizer.bypassSecurityTrustResourceUrl(link); + } + }); + this.store.dispatch(new GetFileMetadata(guid)); + }); + + this.route.parent?.params.subscribe((params) => { + const projectId = params['id']; + if (projectId) { + this.store.dispatch(new GetFileProjectMetadata(projectId)); + this.store.dispatch(new GetFileProjectContributors(projectId)); + } + }); + + toObservable(this.fileMetadata) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((metadata) => { + if (metadata.data) { + this.fileMetadataForm.patchValue({ + title: metadata.data.title, + description: metadata.data.description, + resourceType: metadata.data.resourceTypeGeneral, + resourceLanguage: metadata.data.language, + }); + } + }); + } + + setFileMetadata() { + if (this.fileMetadataForm.valid) { + const formValues = { + title: this.fileMetadataForm.get('title')?.value ?? null, + description: this.fileMetadataForm.get('description')?.value ?? null, + resource_type_general: this.fileMetadataForm.get('resourceType')?.value ?? null, + language: this.fileMetadataForm.get('resourceLanguage')?.value ?? null, + }; + + const fileId = this.file().data?.id; + if (fileId) { + this.store.dispatch(new SetFileMetadata(formValues, fileId)); + } + + this.editFileMetadataVisible = false; + } + } } diff --git a/src/app/features/project/files/components/index.ts b/src/app/features/project/files/components/index.ts index 316bb4b3f..859618dbc 100644 --- a/src/app/features/project/files/components/index.ts +++ b/src/app/features/project/files/components/index.ts @@ -1 +1,2 @@ export { FileDetailComponent } from './file-detail/file-detail.component'; +export { MoveFileDialogComponent } from './move-file-dialog/move-file-dialog.component'; diff --git a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html new file mode 100644 index 000000000..df53f8a19 --- /dev/null +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html @@ -0,0 +1,73 @@ +@if (files().isLoading || isFilesUpdating()) { +
+ +
+} @else { +
+
+ cost-shield +

OSF Storage

+
+ +
+ @if (currentFolder()?.relationships?.parentFolderLink) { +
+
+ +
+
+ } + + @for (file of files().data; track $index) { +
+
+ @if (file.kind !== 'folder') { + +
{{ file?.name ?? '' }}
+ } @else if (config.data.file.id === file.id) { + +
{{ file?.name ?? '' }}
+ } @else { + + + } +
+
+ } + @if (!files().data.length) { +
+

This folder is empty

+
+ } +
+
+} + +
+ + + @if (this.config.data.action === 'move') { + + } @else if (this.config.data.action === 'copy') { + + } +
diff --git a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss new file mode 100644 index 000000000..168826298 --- /dev/null +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss @@ -0,0 +1,77 @@ +@use "assets/styles/variables" as var; +@use "/assets/styles/mixins" as mix; + +:host { + @include mix.flex-column; + flex: 1; + gap: 1.5rem; +} + +.files-table { + display: flex; + flex-direction: column; + border: 1px solid var.$grey-2; + border-radius: 8px; + padding: 0 12px; + + &-row { + color: var.$dark-blue-1; + display: grid; + align-items: center; + grid-template-columns: 3fr 1fr 1fr 1fr 0.5fr; + grid-template-rows: 38px; + border-bottom: 1px solid var.$grey-2; + } + + &-row:last-child { + border-bottom: none; + } +} + +.filename-link { + cursor: pointer; + &:hover { + text-decoration: underline; + } +} + +.parent-folder-link { + cursor: pointer; + display: flex; + gap: 5px; +} + +.sorting-container { + display: flex; + align-items: center; + border: 1px solid var.$grey-2; + border-radius: 8px; + padding: 12px; + height: 44px; +} + +.outline-button { + font-weight: 600; + display: flex; + gap: 8px; + border: 1px solid var.$grey-2; + padding: 12px; + border-radius: 8px; + height: 44px; + + &.blue { + color: var.$pr-blue-1; + } + + &.green { + color: var.$green-1; + } +} + +.filename { + overflow-wrap: anywhere; +} + +.spinner-container { + width: 38px; +} diff --git a/src/app/shared/components/text-input/text-input.component.spec.ts b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.spec.ts similarity index 50% rename from src/app/shared/components/text-input/text-input.component.spec.ts rename to src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.spec.ts index 0652ec4b7..cd4ca3282 100644 --- a/src/app/shared/components/text-input/text-input.component.spec.ts +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.spec.ts @@ -1,17 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TextInputComponent } from './text-input.component'; +import { MoveFileDialogComponent } from './move-file-dialog.component'; -describe('TextInputComponent', () => { - let component: TextInputComponent; - let fixture: ComponentFixture; +describe('MoveFileDialogComponent', () => { + let component: MoveFileDialogComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TextInputComponent], + imports: [MoveFileDialogComponent], }).compileComponents(); - fixture = TestBed.createComponent(TextInputComponent); + fixture = TestBed.createComponent(MoveFileDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts new file mode 100644 index 000000000..78072c0ed --- /dev/null +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts @@ -0,0 +1,112 @@ +import { select, Store } from '@ngxs/store'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Tooltip } from 'primeng/tooltip'; + +import { finalize } from 'rxjs'; + +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; + +import { OsfFile } from '@osf/features/project/files/models'; +import { ProjectFilesService } from '@osf/features/project/files/services/project-files.service'; +import { + GetFiles, + GetMoveFileFiles, + GetRootFolderFiles, + SetCurrentFolder, + SetFilesIsLoading, + SetMoveFileCurrentFolder, +} from '@osf/features/project/files/store/project-files.actions'; +import { ProjectFilesSelectors } from '@osf/features/project/files/store/project-files.selectors'; +import { LoadingSpinnerComponent } from '@shared/components'; + +@Component({ + selector: 'osf-move-file-dialog', + imports: [Button, LoadingSpinnerComponent, NgOptimizedImage, Tooltip], + templateUrl: './move-file-dialog.component.html', + styleUrl: './move-file-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MoveFileDialogComponent { + store = inject(Store); + dialogRef = inject(DynamicDialogRef); + projectFilesService = inject(ProjectFilesService); + config = inject(DynamicDialogConfig); + + protected readonly files = select(ProjectFilesSelectors.getMoveFileFiles); + protected readonly currentFolder = select(ProjectFilesSelectors.getMoveFileCurrentFolder); + protected readonly isFilesUpdating = signal(false); + protected readonly isFolderSame = computed(() => { + return this.currentFolder()?.id === this.config.data.file.relationships.parentFolderId; + }); + + constructor() { + const filesLink = this.currentFolder()?.relationships.filesLink; + if (filesLink) { + this.store.dispatch(new GetMoveFileFiles(filesLink)); + } + } + + openFolder(file: OsfFile) { + if (file.kind !== 'folder') return; + + this.store.dispatch(new GetMoveFileFiles(file.relationships.filesLink)); + this.store.dispatch(new SetMoveFileCurrentFolder(file)); + } + + openParentFolder() { + const currentFolder = this.currentFolder(); + + if (!currentFolder) return; + + this.isFilesUpdating.set(true); + this.projectFilesService + .getFolder(currentFolder.relationships.parentFolderLink) + .pipe( + finalize(() => { + this.isFilesUpdating.set(false); + }) + ) + .subscribe((folder) => { + this.store.dispatch(new SetMoveFileCurrentFolder(folder)); + this.store.dispatch(new GetMoveFileFiles(folder.relationships.filesLink)); + }); + } + + moveFile(): void { + let path = this.currentFolder()?.path; + console.log(this.currentFolder()); + + if (!path) { + throw new Error('Path is not specified!.'); + } + + if (!this.currentFolder()?.relationships.parentFolderLink) { + path = '/'; + } + + this.store.dispatch(new SetFilesIsLoading(true)); + this.projectFilesService + .moveFile(this.config.data.file.links.move, path, this.config.data.projectId, this.config.data.action) + .pipe( + finalize(() => { + this.store.dispatch(new SetCurrentFolder(this.currentFolder())); + this.store.dispatch(new SetMoveFileCurrentFolder(undefined)); + }) + ) + .subscribe((file) => { + if (file.id) { + const filesLink = this.currentFolder()?.relationships.filesLink; + console.log(this.currentFolder()); + if (filesLink) { + this.store.dispatch(new GetFiles(filesLink)); + } else { + this.store.dispatch(new GetRootFolderFiles(this.config.data.projectId)); + } + } + }); + this.dialogRef.close(); + } +} diff --git a/src/app/features/project/files/mappers/file-custom-metadata.mapper.ts b/src/app/features/project/files/mappers/file-custom-metadata.mapper.ts new file mode 100644 index 000000000..a7a16b190 --- /dev/null +++ b/src/app/features/project/files/mappers/file-custom-metadata.mapper.ts @@ -0,0 +1,13 @@ +import { ApiData } from '@osf/core/models'; +import { OsfFileCustomMetadata } from '@osf/features/project/files/models/osf-models/file-custom-metafata.model'; +import { FileCustomMetadata } from '@osf/features/project/files/models/responses/get-file-metadata-reponse.model'; + +export function MapFileCustomMetadata(data: ApiData): OsfFileCustomMetadata { + return { + id: data.id, + description: data.attributes.description, + language: data.attributes.language, + resourceTypeGeneral: data.attributes.resource_type_general, + title: data.attributes.title, + }; +} diff --git a/src/app/features/project/files/mappers/files.mapper.ts b/src/app/features/project/files/mappers/files.mapper.ts new file mode 100644 index 000000000..57c3b23ae --- /dev/null +++ b/src/app/features/project/files/mappers/files.mapper.ts @@ -0,0 +1,49 @@ +import { ApiData } from '@osf/core/models'; +import { FileTargetResponse } from '@osf/features/project/files/models/responses/get-file-target-response.model'; + +import { FileLinks, FileRelationshipsResponse, FileResponse, OsfFile } from '../models'; + +export function MapFiles( + files: ApiData[] +): OsfFile[] { + return files.map((file) => MapFile(file)); +} + +export function MapFile( + file: ApiData +): OsfFile { + return { + id: file.id, + guid: file.attributes.guid, + name: file.attributes.name, + kind: file.attributes.kind, + size: file.attributes.size, + provider: file.attributes.provider, + dateModified: file.attributes.date_modified, + dateCreated: file.attributes.date_created, + extra: file.attributes.extra, + links: file.links, + path: file.attributes.path, + materializedPath: file.attributes.materialized_path, + relationships: { + parentFolderLink: file?.relationships?.parent_folder?.links?.related?.href, + parentFolderId: file?.relationships?.parent_folder?.data?.id, + filesLink: file?.relationships?.files?.links?.related?.href, + }, + target: { + title: file?.embeds?.target.data.attributes.title, + description: file?.embeds?.target.data.attributes.description, + category: file?.embeds?.target.data.attributes.category, + customCitation: file?.embeds?.target.data.attributes.custom_citation, + dateCreated: file?.embeds?.target.data.attributes.date_created, + dateModified: file?.embeds?.target.data.attributes.date_modified, + registration: file?.embeds?.target.data.attributes.registration, + preprint: file?.embeds?.target.data.attributes.preprint, + fork: file?.embeds?.target.data.attributes.fork, + collection: file?.embeds?.target.data.attributes.collection, + tags: file?.embeds?.target.data.attributes.tags, + nodeLicense: file?.embeds?.target.data.attributes.node_license, + analyticsKey: file?.embeds?.target.data.attributes.analytics_key, + }, + } as OsfFile; +} diff --git a/src/app/features/project/files/mappers/index.ts b/src/app/features/project/files/mappers/index.ts new file mode 100644 index 000000000..36c05ea5b --- /dev/null +++ b/src/app/features/project/files/mappers/index.ts @@ -0,0 +1,3 @@ +export * from './file-custom-metadata.mapper'; +export * from './files.mapper'; +export * from './project-metadata.mapper'; diff --git a/src/app/features/project/files/mappers/project-metadata.mapper.ts b/src/app/features/project/files/mappers/project-metadata.mapper.ts new file mode 100644 index 000000000..7eb0e85f6 --- /dev/null +++ b/src/app/features/project/files/mappers/project-metadata.mapper.ts @@ -0,0 +1,26 @@ +import { GetProjectCustomMetadataResponse } from '@osf/features/project/files/models/responses/get-project-custom-metadata-response.model'; +import { GetProjectShortInfoResponse } from '@osf/features/project/files/models/responses/get-project-short-info-response.model'; + +import { OsfProjectMetadata } from '../models/osf-models/project-custom-metadata.model'; + +export function MapProjectMetadata( + shortInfo: GetProjectShortInfoResponse, + customMetadata: GetProjectCustomMetadataResponse +): OsfProjectMetadata { + return { + title: shortInfo.data.attributes.title, + description: shortInfo.data.attributes.description, + dateCreated: new Date(shortInfo.data.attributes.date_created), + dateModified: new Date(shortInfo.data.attributes.date_modified), + funders: customMetadata.data.embeds.custom_metadata.data.attributes.funders.map((funder) => ({ + funderName: funder.funder_name, + funderIdentifier: funder.funder_identifier, + funderIdentifierType: funder.funder_identifier_type, + awardNumber: funder.award_number, + awardUri: funder.award_uri, + awardTitle: funder.award_title, + })), + language: customMetadata.data.embeds.custom_metadata.data.attributes.language, + resourceTypeGeneral: customMetadata.data.embeds.custom_metadata.data.attributes.resource_type_general, + }; +} diff --git a/src/app/features/project/files/model/index.ts b/src/app/features/project/files/model/index.ts deleted file mode 100644 index 5d4ef9f7e..000000000 --- a/src/app/features/project/files/model/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './project-files.models'; diff --git a/src/app/features/project/files/model/project-files.models.ts b/src/app/features/project/files/model/project-files.models.ts deleted file mode 100644 index 8ba92723e..000000000 --- a/src/app/features/project/files/model/project-files.models.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type FolderType = 'file' | 'folder'; - -export type FileType = 'pdf' | 'doc'; - -export interface FileItem { - id?: number; - name: string; - type: FolderType; - downloadType?: FileType; - downloads?: number; - size?: string; - modifiedAt?: Date; - children?: FileItem[]; -} - -export const FILES: FileItem[] = [ - { - name: 'folder name example', - type: 'folder', - children: [ - { - id: 0, - name: 'filerandomname.doc', - type: 'file', - downloadType: 'doc', - downloads: 12, - size: '1.2 MB', - modifiedAt: new Date('2023-10-01T12:00:00'), - }, - { - id: 1, - name: 'filerandomname.pdf', - type: 'file', - downloadType: 'pdf', - downloads: 12, - size: '1.2 MB', - modifiedAt: new Date('2023-10-01T12:00:00'), - }, - ], - }, - { - id: 3, - name: 'filerandomname.doc', - type: 'file', - downloadType: 'doc', - downloads: 12, - size: '1.2 MB', - modifiedAt: new Date('2023-10-01T12:00:00'), - }, -]; diff --git a/src/app/features/project/files/models/data/file-menu-items.const.ts b/src/app/features/project/files/models/data/file-menu-items.const.ts new file mode 100644 index 000000000..c8e0c5626 --- /dev/null +++ b/src/app/features/project/files/models/data/file-menu-items.const.ts @@ -0,0 +1,7 @@ +export const FileMenuItems = { + Download: 'Download', + Copy: 'Copy', + Move: 'Move', + Delete: 'Delete', + Rename: 'Rename', +}; diff --git a/src/app/features/project/files/models/data/file-provider.const.ts b/src/app/features/project/files/models/data/file-provider.const.ts new file mode 100644 index 000000000..546a640d5 --- /dev/null +++ b/src/app/features/project/files/models/data/file-provider.const.ts @@ -0,0 +1,9 @@ +export const FileProvider = { + OsfStorage: 'osfstorage', + GoogleDrive: 'google-drive', + Box: 'box', + DropBox: 'dropbox', + OneDrive: 'onedrive', + WebDav: 'webdav', + S3: 's3', +}; diff --git a/src/app/features/project/files/models/data/files-sort.const.ts b/src/app/features/project/files/models/data/files-sort.const.ts new file mode 100644 index 000000000..290305b3e --- /dev/null +++ b/src/app/features/project/files/models/data/files-sort.const.ts @@ -0,0 +1,6 @@ +export const FilesSorting = { + NameAZ: 'name', + NameZA: '-name', + LastModifiedOldest: 'date_modified', + LastModifiedNewest: '-date_modified', +}; diff --git a/src/app/features/project/files/models/index.ts b/src/app/features/project/files/models/index.ts new file mode 100644 index 000000000..77cb86f7c --- /dev/null +++ b/src/app/features/project/files/models/index.ts @@ -0,0 +1,24 @@ +// OSF Models +export * from './osf-models/file-custom-metafata.model'; +export * from './osf-models/file-project-contributor.model'; +export * from './osf-models/file-system-entry.model'; +export * from './osf-models/file-target.model'; +export * from './osf-models/project-custom-metadata.model'; +export * from './osf-models/project-short-info.model'; + +// Response Models +export * from './responses/create-folder-response.model'; +export * from './responses/get-file-metadata-reponse.model'; +export * from './responses/get-file-target-response.model'; +export * from './responses/get-files-response.model'; +export * from './responses/get-project-contributors-response.model'; +export * from './responses/get-project-custom-metadata-response.model'; +export * from './responses/get-project-short-info-response.model'; + +// Request Models +export * from './requests/patch-file-metadata.mode'; + +// Constants +export * from './data/file-menu-items.const'; +export * from './data/file-provider.const'; +export * from './data/files-sort.const'; diff --git a/src/app/features/project/files/models/osf-models/file-custom-metafata.model.ts b/src/app/features/project/files/models/osf-models/file-custom-metafata.model.ts new file mode 100644 index 000000000..aace1c784 --- /dev/null +++ b/src/app/features/project/files/models/osf-models/file-custom-metafata.model.ts @@ -0,0 +1,7 @@ +export interface OsfFileCustomMetadata { + id: string; + language: string; + resourceTypeGeneral: string; + title: string; + description: string; +} diff --git a/src/app/features/project/files/models/osf-models/file-project-contributor.model.ts b/src/app/features/project/files/models/osf-models/file-project-contributor.model.ts new file mode 100644 index 000000000..b31c96060 --- /dev/null +++ b/src/app/features/project/files/models/osf-models/file-project-contributor.model.ts @@ -0,0 +1,5 @@ +export interface OsfFileProjectContributor { + id: string; + name: string; + active: boolean; +} diff --git a/src/app/features/project/files/models/osf-models/file-system-entry.model.ts b/src/app/features/project/files/models/osf-models/file-system-entry.model.ts new file mode 100644 index 000000000..9f510aaa2 --- /dev/null +++ b/src/app/features/project/files/models/osf-models/file-system-entry.model.ts @@ -0,0 +1,35 @@ +import { FileLinks } from '@osf/features/project/files/models'; + +import { OsfFileTarget } from './file-target.model'; + +export interface OsfFile { + id: string; + guid: string; + name: string; + kind: string; + path: string; + size: number; + provider: string; + materializedPath: string; + lastTouched: null; + dateModified: string; + dateCreated: string; + extra: { + hashes: { + md5: string; + sha256: string; + }; + downloads: number; + }; + tags: []; + currentUserCanComment: boolean; + currentVersion: number; + showAsUnviewed: boolean; + links: FileLinks; + relationships: { + parentFolderLink: string; + parentFolderId: string; + filesLink: string; + }; + target: OsfFileTarget; +} diff --git a/src/app/features/project/files/models/osf-models/file-target.model.ts b/src/app/features/project/files/models/osf-models/file-target.model.ts new file mode 100644 index 000000000..84485a11e --- /dev/null +++ b/src/app/features/project/files/models/osf-models/file-target.model.ts @@ -0,0 +1,21 @@ +export interface OsfFileTarget { + title: string; + description: string; + category: string; + customCitation: string | null; + dateCreated: string; + dateModified: string; + registration: boolean; + preprint: boolean; + fork: boolean; + collection: boolean; + tags: string[]; + nodeLicense: string | null; + analyticsKey: string; + currentUserCanComment: boolean; + currentUserPermissions: string[]; + currentUserIsContributor: boolean; + currentUserIsContributorOrGroupMember: boolean; + wikiEnabled: boolean; + public: boolean; +} diff --git a/src/app/features/project/files/models/osf-models/project-custom-metadata.model.ts b/src/app/features/project/files/models/osf-models/project-custom-metadata.model.ts new file mode 100644 index 000000000..fcb5d2322 --- /dev/null +++ b/src/app/features/project/files/models/osf-models/project-custom-metadata.model.ts @@ -0,0 +1,16 @@ +export interface OsfProjectMetadata { + title: string; + description: string; + dateCreated: Date; + dateModified: Date; + language: string; + resourceTypeGeneral: string; + funders: { + funderName: string; + funderIdentifier: string; + funderIdentifierType: string; + awardNumber: string; + awardUri: string; + awardTitle: string; + }[]; +} diff --git a/src/app/features/project/files/models/osf-models/project-short-info.model.ts b/src/app/features/project/files/models/osf-models/project-short-info.model.ts new file mode 100644 index 000000000..424b9e43e --- /dev/null +++ b/src/app/features/project/files/models/osf-models/project-short-info.model.ts @@ -0,0 +1,7 @@ +export interface OsfProjectShortInfo { + id: string; + title: string; + description: string; + dateCreated: string; + dateModified: string; +} diff --git a/src/app/features/project/files/models/requests/patch-file-metadata.mode.ts b/src/app/features/project/files/models/requests/patch-file-metadata.mode.ts new file mode 100644 index 000000000..b2a9869dc --- /dev/null +++ b/src/app/features/project/files/models/requests/patch-file-metadata.mode.ts @@ -0,0 +1,6 @@ +export interface PatchFileMetadata { + description: string | null; + language: string | null; + title: string | null; + resource_type_general: string | null; +} diff --git a/src/app/features/project/files/models/responses/create-folder-response.model.ts b/src/app/features/project/files/models/responses/create-folder-response.model.ts new file mode 100644 index 000000000..6159da51e --- /dev/null +++ b/src/app/features/project/files/models/responses/create-folder-response.model.ts @@ -0,0 +1,5 @@ +import { ApiData } from '@osf/core/models'; +import { FileLinks, FileRelationshipsResponse, FileResponse } from '@osf/features/project/files/models'; +import { FileTargetResponse } from '@osf/features/project/files/models/responses/get-file-target-response.model'; + +export type CreateFolderResponse = ApiData; diff --git a/src/app/features/project/files/models/responses/get-file-metadata-reponse.model.ts b/src/app/features/project/files/models/responses/get-file-metadata-reponse.model.ts new file mode 100644 index 000000000..394fa30de --- /dev/null +++ b/src/app/features/project/files/models/responses/get-file-metadata-reponse.model.ts @@ -0,0 +1,10 @@ +import { ApiData, JsonApiResponse } from '@core/models'; + +export type GetFileMetadataResponse = JsonApiResponse, null>; + +export interface FileCustomMetadata { + language: string; + resource_type_general: string; + title: string; + description: string; +} diff --git a/src/app/features/project/files/models/responses/get-file-target-response.model.ts b/src/app/features/project/files/models/responses/get-file-target-response.model.ts new file mode 100644 index 000000000..620e65d7b --- /dev/null +++ b/src/app/features/project/files/models/responses/get-file-target-response.model.ts @@ -0,0 +1,39 @@ +import { ApiData, JsonApiResponse } from '@core/models'; +import { FileLinks, FileRelationshipsResponse, FileResponse } from '@osf/features/project/files/models'; + +export type GetFileTargetResponse = JsonApiResponse< + ApiData, + null +>; + +export interface FileTargetResponse { + target: JsonApiResponse< + ApiData< + { + title: string; + description: string; + category: string; + custom_citation: string; + date_created: string; + date_modified: string; + registration: boolean; + preprint: boolean; + fork: boolean; + collection: boolean; + tags: string[]; + node_license: string; + analytics_key: string; + current_user_can_comment: boolean; + current_user_permissions: string[]; + current_user_is_contributor: boolean; + current_user_is_contributor_or_group_member: boolean; + wiki_enabled: boolean; + public: boolean; + }, + null, + null, + null + >, + null + >; +} diff --git a/src/app/features/project/files/models/responses/get-files-response.model.ts b/src/app/features/project/files/models/responses/get-files-response.model.ts new file mode 100644 index 000000000..954091783 --- /dev/null +++ b/src/app/features/project/files/models/responses/get-files-response.model.ts @@ -0,0 +1,65 @@ +import { ApiData, JsonApiResponse } from '@osf/core/models'; +import { FileTargetResponse } from '@osf/features/project/files/models/responses/get-file-target-response.model'; + +export type GetFilesResponse = JsonApiResponse< + ApiData[], + null +>; +export type GetFileResponse = ApiData; +export type AddFileResponse = ApiData; + +export interface FileResponse { + guid: string; + name: string; + kind: string; + path: string; + size: number; + provider: string; + materialized_path: string; + last_touched: null; + date_modified: string; + date_created: string; + extra: { + hashes: { + md5: string; + sha256: string; + }; + downloads: number; + }; + tags: []; + current_user_can_comment: boolean; + current_version: number; + show_as_unviewed: boolean; +} + +export interface FileRelationshipsResponse { + parent_folder: { + links: { + related: { + href: string; + }; + }; + data: { + id: string; + type: string; + }; + }; + files: { + links: { + related: { + href: string; + }; + }; + }; +} + +export interface FileLinks { + info: string; + move: string; + upload: string; + delete: string; + download: string; + self: string; + html: string; + render: string; +} diff --git a/src/app/features/project/files/models/responses/get-project-contributors-response.model.ts b/src/app/features/project/files/models/responses/get-project-contributors-response.model.ts new file mode 100644 index 000000000..a33491a3c --- /dev/null +++ b/src/app/features/project/files/models/responses/get-project-contributors-response.model.ts @@ -0,0 +1,14 @@ +import { ApiData, JsonApiResponse } from '@core/models'; + +export type GetProjectContributorsResponse = JsonApiResponse< + ApiData< + { + full_name: string; + active: boolean; + }, + null, + null, + null + >[], + null +>; diff --git a/src/app/features/project/files/models/responses/get-project-custom-metadata-response.model.ts b/src/app/features/project/files/models/responses/get-project-custom-metadata-response.model.ts new file mode 100644 index 000000000..65076135e --- /dev/null +++ b/src/app/features/project/files/models/responses/get-project-custom-metadata-response.model.ts @@ -0,0 +1,29 @@ +import { ApiData, JsonApiResponse } from '@core/models'; + +export type GetProjectCustomMetadataResponse = JsonApiResponse< + ApiData, + null +>; + +export interface ProjectMetadataEmbedResponse { + custom_metadata: JsonApiResponse< + ApiData< + { + language: string; + resource_type_general: string; + funders: { + funder_name: string; + funder_identifier: string; + funder_identifier_type: string; + award_number: string; + award_uri: string; + award_title: string; + }[]; + }, + null, + null, + null + >, + null + >; +} diff --git a/src/app/features/project/files/models/responses/get-project-short-info-response.model.ts b/src/app/features/project/files/models/responses/get-project-short-info-response.model.ts new file mode 100644 index 000000000..8f4f45870 --- /dev/null +++ b/src/app/features/project/files/models/responses/get-project-short-info-response.model.ts @@ -0,0 +1,16 @@ +import { ApiData, JsonApiResponse } from '@core/models'; + +export type GetProjectShortInfoResponse = JsonApiResponse< + ApiData< + { + title: string; + description: string; + date_created: string; + date_modified: string; + }, + null, + null, + null + >, + null +>; diff --git a/src/app/features/project/files/project-files.component.html b/src/app/features/project/files/project-files.component.html index 36d5ca7ba..9987f6848 100644 --- a/src/app/features/project/files/project-files.component.html +++ b/src/app/features/project/files/project-files.component.html @@ -1,122 +1,299 @@ - - + - +
+
+ + + + +
-@if (!selectFile()) { - +
+
+ -
-
- - - - +
+
+ + + {{ selectedOption.label | translate }} + + + {{ item.label | translate }} + +
+
-
- - -
- +
+
+ @let id = currentFolder()?.id ?? ''; + @let rootFolder = !currentFolder()?.relationships?.parentFolderLink; + +
+ + {{ 'project.files.actions.downloadAsZip' | translate }} +
+
-
-
- +
+ +
+ + {{ 'project.files.actions.createFolder' | translate }} +
+
+
- Download As Zip -
+
+ +
+ + {{ 'project.files.actions.uploadFile' | translate }} +
+
+
-
- + +
+
- Create Folder +
+ +
+ {{ fileName() }} +
+
+

{{ progress() }} %

+
+
-
- + +
+ + +
+
+ @if (!isFolderCreating) { + + + } @else { +
+ +
+ } +
+
- Upload File -
+ +
+ +
-
+
+ + +
+ +
+ @if (files().isLoading || isFilesUpdating()) { +
+ +
+ } @else {
- @for (file of files(); track file.downloads) { + @if (currentFolder()?.relationships?.parentFolderLink) {
-
- @if (file.downloadType && file.type !== 'folder') { - @if (file.downloadType === 'doc') { - - } @else { - - } +
+ +
+ +
+
+
+
+
+ } + @for (file of files().data; track $index) { +
+
+ @if (file.kind !== 'folder') { + } @else { + } - -
- {{ file.name }} -
-
{{ file.downloads }} Downloads
+ +
{{ file.kind === 'file' ? file.extra.downloads + ' Downloads' : '' }}
- {{ file.size }} + {{ file.size ? formatFileSize(file.size) : '' }}
- {{ file.modifiedAt | date: 'MMM d, y hh:mm a' }} + {{ file.dateModified | date: 'MMM d, y hh:mm a' }}
-
+
+ + {{ 'project.files.menu.download' | translate }} + + @switch (item.label) { + @case (FileMenuItems.Download) { + @if (file.kind === 'file') { + + {{ 'project.files.menu.download' | translate }} + + } @else { + + {{ 'project.files.menu.download' | translate }} + + } + } + @case (FileMenuItems.Copy) { + + {{ 'project.files.menu.copy' | translate }} + + } + @case (FileMenuItems.Move) { + + {{ 'project.files.menu.move' | translate }} + + } + @case (FileMenuItems.Rename) { + + {{ 'project.files.menu.rename' | translate }} + + } + @case (FileMenuItems.Delete) { + + {{ 'project.files.menu.delete' | translate }} + + } + } + + +
} + @if (!files().data.length) { +
+

{{ 'project.files.emptyState' | translate }}

+
+ }
-
-} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + } +
diff --git a/src/app/features/project/files/project-files.component.scss b/src/app/features/project/files/project-files.component.scss index cfc68520a..e4e7d3372 100644 --- a/src/app/features/project/files/project-files.component.scss +++ b/src/app/features/project/files/project-files.component.scss @@ -11,15 +11,39 @@ flex-direction: column; border: 1px solid var.$grey-2; border-radius: 8px; - padding: 0 12px; + overflow-x: auto; + min-width: 100%; &-row { color: var.$dark-blue-1; display: grid; align-items: center; - grid-template-columns: 3fr 1fr 1fr 1fr 0.5fr; - grid-template-rows: 38px; + grid-template-columns: + minmax(mix.rem(200px), 32rem) minmax(mix.rem(150px), 0.7fr) minmax(mix.rem(100px), 100px) + minmax(mix.rem(150px), 1fr) minmax(mix.rem(50px), 50px); + grid-template-rows: mix.rem(44px); border-bottom: 1px solid var.$grey-2; + padding: 0 mix.rem(12px); + min-width: max-content; + + > div { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + + > div:first-child { + min-width: 0; + max-width: 95%; + } + + .flex { + min-width: 0; + } + a.flex { + min-width: 0; + } } &-row:last-child { @@ -27,23 +51,53 @@ } } +.filename-link { + cursor: pointer; + + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; + + &:hover { + text-decoration: underline; + } +} + +.icon-link { + cursor: pointer; + &:hover { + text-decoration: underline; + } +} + +.parent-folder-link { + cursor: pointer; + display: flex; + gap: 5px; +} + .sorting-container { display: flex; align-items: center; border: 1px solid var.$grey-2; - border-radius: 8px; - padding: 12px; - height: 44px; + border-radius: mix.rem(8px); + padding: mix.rem(12px); + height: mix.rem(44px); } .outline-button { font-weight: 600; display: flex; - gap: 8px; + justify-content: center; + gap: mix.rem(8px); border: 1px solid var.$grey-2; - padding: 12px; - border-radius: 8px; - height: 44px; + padding: mix.rem(12px); + border-radius: mix.rem(8px); + height: mix.rem(44px); + min-height: mix.rem(44px); &.blue { color: var.$pr-blue-1; @@ -53,3 +107,11 @@ color: var.$green-1; } } + +.filename { + overflow-wrap: anywhere; +} + +.spinner-container { + width: mix.rem(38px); +} diff --git a/src/app/features/project/files/project-files.component.ts b/src/app/features/project/files/project-files.component.ts index 1737501e5..ad16b436c 100644 --- a/src/app/features/project/files/project-files.component.ts +++ b/src/app/features/project/files/project-files.component.ts @@ -1,47 +1,335 @@ +import { select, Store } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + import { Button } from 'primeng/button'; +import { Dialog } from 'primeng/dialog'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { FloatLabel } from 'primeng/floatlabel'; +import { InputText } from 'primeng/inputtext'; +import { Menu } from 'primeng/menu'; import { Select } from 'primeng/select'; import { TableModule } from 'primeng/table'; -import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, HostBinding, inject, signal } from '@angular/core'; -import { Router } from '@angular/router'; +import { debounceTime, filter, finalize, forkJoin, skip } from 'rxjs'; -import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; +import { DatePipe } from '@angular/common'; +import { HttpEventType } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, ElementRef, HostBinding, inject, signal, ViewChild } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { FormControl, FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { FileItem, FILES } from './model'; +import { MoveFileDialogComponent } from '@osf/features/project/files/components'; +import { FileMenuItems, FilesSorting, OsfFile } from '@osf/features/project/files/models'; +import { ProjectFilesService } from '@osf/features/project/files/services/project-files.service'; +import { + CreateFolder, + DeleteEntry, + GetFiles, + GetRootFolderFiles, + RenameEntry, + SetCurrentFolder, + SetMoveFileCurrentFolder, + SetSearch, + SetSort, +} from '@osf/features/project/files/store/project-files.actions'; +import { ProjectFilesSelectors } from '@osf/features/project/files/store/project-files.selectors'; +import { LoadingSpinnerComponent, SearchInputComponent, SubHeaderComponent } from '@shared/components'; +import { formatFileSize } from '@shared/utils/format-file-size.helper'; @Component({ selector: 'osf-project-files', - imports: [TableModule, Button, DatePipe, Select, FloatLabel, SubHeaderComponent, SearchInputComponent], + imports: [ + TableModule, + Button, + DatePipe, + FloatLabel, + SubHeaderComponent, + SearchInputComponent, + Select, + LoadingSpinnerComponent, + Dialog, + InputText, + FormsModule, + Menu, + TranslatePipe, + RouterLink, + ], templateUrl: './project-files.component.html', styleUrl: './project-files.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService], }) export class ProjectFilesComponent { @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; - protected readonly files = signal(FILES); - protected readonly folderOpened = signal(false); - protected readonly selectFile = signal(null); - readonly #router = inject(Router); + @ViewChild('fileInput') fileInput!: ElementRef; + + readonly store = inject(Store); + readonly projectFilesService = inject(ProjectFilesService); + readonly router = inject(Router); + readonly activeRoute = inject(ActivatedRoute); + + protected readonly files = select(ProjectFilesSelectors.getFiles); + protected readonly currentFolder = select(ProjectFilesSelectors.getCurrentFolder); + + protected readonly projectId = signal(''); + protected readonly progress = signal(0); + protected readonly fileName = signal(''); + protected readonly newFolderName = signal(''); + protected readonly isFilesUpdating = signal(false); + protected readonly renamedEntry = signal(''); + protected readonly searchControl = new FormControl(''); + + dialogRef: DynamicDialogRef | null = null; + readonly #dialogService = inject(DialogService); + + fileIsUploading = false; + isFolderCreating = false; + selectedFileIndex = -1; + + // dialogs + createFolderVisible = false; + renameFileVisible = false; + + items = [ + { label: FileMenuItems.Download }, + { label: FileMenuItems.Copy }, + { label: FileMenuItems.Move }, + { label: FileMenuItems.Delete }, + { label: FileMenuItems.Rename }, + ]; + + sortOptions = [ + { + value: FilesSorting.NameAZ, + label: 'Name: A-Z', + }, + { + value: FilesSorting.NameZA, + label: 'Name: Z-A', + }, + { + value: FilesSorting.LastModifiedOldest, + label: 'Last modified: oldest to newest', + }, + { + value: FilesSorting.LastModifiedNewest, + label: 'Last modified: newest to oldest', + }, + ]; + + #defaultSortValue = this.sortOptions[0].value; + protected readonly sortValue = signal(this.#defaultSortValue); + readonly isBatching = signal(false); + + protected readonly FileMenuItems = FileMenuItems; + + constructor() { + // get root folder files + this.activeRoute.parent?.params.subscribe((params) => { + if (params['id']) { + this.projectId.set(params['id']); + this.store.dispatch(new GetRootFolderFiles(params['id'])); + } + }); + + // put search value in store and update resources, filters + this.searchControl.valueChanges + .pipe( + skip(1), //skip default value from the store + debounceTime(500), + filter(() => !this.isBatching()) // only run if not in batch mode + ) + .subscribe((searchText) => { + this.store.dispatch(new SetSearch(searchText ?? '')); + this.updateFilesList(); + }); + + toObservable(this.sortValue) + .pipe( + skip(1), //skip default value from the store + filter(() => !this.isBatching()) // only run if not in batch mode + ) + .subscribe((sort) => { + this.store.dispatch(new SetSort(sort)); + this.updateFilesList(); + }); + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + this.fileName.set(file.name); + this.fileIsUploading = true; + this.projectFilesService + .uploadFile(file, this.projectId(), this.currentFolder()) + .pipe( + finalize(() => { + this.fileIsUploading = false; + this.fileName.set(''); + input.value = ''; + this.updateFilesList(); + }) + ) + .subscribe((event) => { + if (event.type === HttpEventType.UploadProgress && event.total) { + this.progress.set(Math.round((event.loaded / event.total) * 100)); + } + + if (event.type === HttpEventType.Response) { + if (event.body) { + const fileId = event?.body?.data.id; + this.approveFile(fileId); + } + } + }); + } + + openFolder(file: OsfFile) { + if (file.kind !== 'folder') return; + this.isFilesUpdating.set(true); + this.isBatching.set(true); - selectFolder(folder: FileItem): void { - if (this.folderOpened()) { - this.folderOpened.set(false); - this.files.set(FILES); + forkJoin([ + this.store.dispatch(new SetCurrentFolder(file)), + this.store.dispatch(new SetSearch('')), + this.store.dispatch(new SetSort(this.#defaultSortValue)), + ]).subscribe(() => { + this.searchControl.setValue(''); + this.sortValue.set(this.#defaultSortValue); + this.isBatching.set(false); + this.updateFilesList(); + }); + } + + openParentFolder() { + const currentFolder = this.currentFolder(); + + if (!currentFolder) return; + + this.isFilesUpdating.set(true); + this.isBatching.set(true); + + this.projectFilesService.getFolder(currentFolder.relationships.parentFolderLink).subscribe((folder) => { + forkJoin([ + this.store.dispatch(new SetCurrentFolder(folder)), + this.store.dispatch(new SetSearch('')), + this.store.dispatch(new SetSort(this.#defaultSortValue)), + ]).subscribe(() => { + this.searchControl.setValue(''); + this.sortValue.set(this.#defaultSortValue); + this.isBatching.set(false); + this.updateFilesList(); + }); + }); + } - return; + createFolder(): void { + this.isFolderCreating = true; + if (this.newFolderName()) { + this.store + .dispatch( + new CreateFolder(this.projectId(), this.newFolderName(), this.currentFolder()?.relationships?.parentFolderId) + ) + .subscribe(() => { + this.createFolderVisible = false; + this.isFolderCreating = false; + this.newFolderName.set(''); + }); } + } + + deleteEntry(link: string): void { + this.isFilesUpdating.set(true); + this.store.dispatch(new DeleteEntry(this.projectId(), link)).subscribe(() => { + this.isFilesUpdating.set(false); + }); + } - if (folder.children && folder.children.length > 0) { - this.folderOpened.set(true); - this.files.set([folder, ...folder.children]); + renameEntry(): void { + this.renameFileVisible = false; + this.isFilesUpdating.set(true); + const link = this.files().data[this.selectedFileIndex].links.upload; + if (this.renamedEntry()) { + this.store + .dispatch(new RenameEntry(this.projectId(), link, this.renamedEntry())) + .pipe( + finalize(() => { + this.isFilesUpdating.set(false); + + this.selectedFileIndex = -1; + this.renamedEntry.set(''); + }) + ) + .subscribe(); } } - navigateToFile(file: FileItem): void { - if (file.type === 'file') { - this.#router.navigate(['/my-projects', 'project', 'files', file.id]); + downloadFile(link: string): void { + window.open(link, '_blank')?.focus(); + } + + downloadFolder(folderId: string, rootFolder: boolean): void { + const projectId = this.projectId(); + if (projectId && folderId) { + if (rootFolder) { + const link = this.projectFilesService.getFolderDownloadLink(projectId, '', true); + window.open(link, '_blank')?.focus(); + } else { + const link = this.projectFilesService.getFolderDownloadLink(projectId, folderId, false); + window.open(link, '_blank')?.focus(); + } + } + } + + approveFile(fileId: string): void { + const projectId = this.projectId(); + const link = `https://staging4.osf.io/api/v1/${projectId}/files/${fileId}/`; + + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = link; + + // Optionally clean up the iframe after it's done + iframe.onload = () => { + setTimeout(() => iframe.remove(), 3000); // wait just in case redirects + }; + + document.body.appendChild(iframe); + } + + protected readonly formatFileSize = formatFileSize; + + moveFile(file: OsfFile, action: string): void { + this.store.dispatch(new SetMoveFileCurrentFolder(this.currentFolder())).subscribe(() => { + const header = action === 'move' ? 'Move file' : 'Copy file'; + this.dialogRef = this.#dialogService.open(MoveFileDialogComponent, { + width: '552px', + focusOnShow: false, + header: header, + closeOnEscape: true, + modal: true, + closable: true, + data: { + file: file, + projectId: this.projectId(), + action: action, + }, + }); + }); + } + + updateFilesList(): void { + const currentFolder = this.currentFolder(); + if (currentFolder?.relationships.filesLink) { + this.store.dispatch(new GetFiles(currentFolder?.relationships.filesLink)).subscribe(() => { + this.isFilesUpdating.set(false); + }); + } else { + this.store.dispatch(new GetRootFolderFiles(this.projectId())); } } } diff --git a/src/app/features/project/files/services/project-files.service.ts b/src/app/features/project/files/services/project-files.service.ts new file mode 100644 index 000000000..75c6e5048 --- /dev/null +++ b/src/app/features/project/files/services/project-files.service.ts @@ -0,0 +1,203 @@ +import { select } from '@ngxs/store'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HttpEvent } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { ApiData, JsonApiResponse } from '@core/models'; +import { JsonApiService } from '@core/services'; +import { + AddFileResponse, + CreateFolderResponse, + FileCustomMetadata, + GetFileMetadataResponse, + GetFileResponse, + GetFilesResponse, + GetFileTargetResponse, + GetProjectContributorsResponse, + GetProjectCustomMetadataResponse, + GetProjectShortInfoResponse, + OsfFile, + OsfFileCustomMetadata, + OsfFileProjectContributor, + PatchFileMetadata, +} from '@osf/features/project/files/models'; +import { ProjectFilesSelectors } from '@osf/features/project/files/store/project-files.selectors'; + +import { environment } from '../../../../../environments/environment'; +import { MapFile, MapFileCustomMetadata, MapFiles } from '../mappers'; + +@Injectable({ + providedIn: 'root', +}) +export class ProjectFilesService { + #jsonApiService = inject(JsonApiService); + provider = select(ProjectFilesSelectors.getProvider); + filesFields = 'name,guid,kind,extra,size,path,materialized_path,date_modified,parent_folder,files'; + + getRootFolderFiles(projectId: string, search: string, sort: string): Observable { + const params: Record = { + sort: sort, + 'fields[files]': this.filesFields, + 'filter[name]': search, + }; + + return this.#jsonApiService + .get(`${environment.apiUrl}/nodes/${projectId}/files/${this.provider()}/`, params) + .pipe(map((response) => MapFiles(response.data))); + } + + getFiles(filesLink: string, search: string, sort: string): Observable { + const params: Record = { + sort: sort, + 'fields[files]': this.filesFields, + 'filter[name]': search, + }; + + return this.#jsonApiService + .get(`${filesLink}`, params) + .pipe(map((response) => MapFiles(response.data))); + } + + uploadFile( + file: File, + projectId: string, + parentFolder?: OsfFile + ): Observable>> { + const params = { + kind: 'file', + name: file.name, + }; + + let link = ''; + + if (parentFolder?.relationships.parentFolderLink) { + link = `${environment.fileApiUrl}/resources/${projectId}/providers/${this.provider()}/${parentFolder?.id ? parentFolder?.id + '/' : ''}`; + } else { + link = `${environment.fileApiUrl}/resources/${projectId}/providers/${this.provider()}/`; + } + + return this.#jsonApiService.putFile(link, file, params); + } + + createFolder(projectId: string, folderName: string, parentFolderId?: string): Observable { + const params: Record = { + kind: 'folder', + name: folderName, + }; + + console.log(); + + const link = `${environment.fileApiUrl}/resources/${projectId}/providers/${this.provider()}/${parentFolderId ? parentFolderId + '/' : ''}`; + + return this.#jsonApiService + .put(link, null, params) + .pipe(map((response) => MapFile(response))); + } + + getFolder(link: string): Observable { + return this.#jsonApiService + .get>(link) + .pipe(map((response) => MapFile(response.data))); + } + + deleteEntry(link: string) { + return this.#jsonApiService.delete(link); + } + + renameEntry(link: string, name: string) { + const body = { + action: 'rename', + rename: name, + conflict: '', + }; + return this.#jsonApiService.post(link, body); + } + + moveFile(link: string, path: string, projectId: string, action: string): Observable { + const body = { + action: action, + path: path, + provider: this.provider(), + resource: projectId, + }; + + return this.#jsonApiService.post(link, body).pipe(map((response) => MapFile(response))); + } + + getFolderDownloadLink(projectId: string, folderId: string, isRootFolder: boolean): string { + if (isRootFolder) { + return `${environment.fileApiUrl}/resources/${projectId}/providers/${this.provider()}/?zip=`; + } + return `${environment.fileApiUrl}/resources/${projectId}/providers/${this.provider()}/${folderId}/?zip=`; + } + + getFileTarget(fileGuid: string): Observable { + return this.#jsonApiService + .get(`${environment.apiUrl}/files/${fileGuid}/?embed=target`) + .pipe(map((response) => MapFile(response.data))); + } + + getFileMetadata(fileGuid: string): Observable { + return this.#jsonApiService + .get(`${environment.apiUrl}/custom_file_metadata_records/${fileGuid}/`) + .pipe(map((response) => MapFileCustomMetadata(response.data))); + } + + getProjectShortInfo(projectId: string): Observable { + const params = { + 'field[nodes]': 'title,description,date_created,date_mofified', + embed: 'bibliographic_contributors', + }; + return this.#jsonApiService.get(`${environment.apiUrl}/nodes/${projectId}/`, params); + } + + getProjectCustomMetadata(projectId: string): Observable { + return this.#jsonApiService.get( + `${environment.apiUrl}/guids/${projectId}/?embed=custom_metadata&resolve=false` + ); + } + + getProjectContributors(projectId: string): Observable { + const params = { + 'page[size]': '50', + 'fields[users]': 'full_name,active', + }; + + return this.#jsonApiService + .get( + `${environment.apiUrl}/nodes/${projectId}/contributors_and_group_members/`, + params + ) + .pipe( + map((response) => + response.data.map((user) => ({ + id: user.id, + name: user.attributes.full_name, + active: user.attributes.active, + })) + ) + ); + } + + patchFileMetadata(data: PatchFileMetadata, fileGuid: string): Observable { + const payload = { + data: { + id: fileGuid, + type: 'custom_file_metadata_records', + attributes: data, + }, + }; + return this.#jsonApiService + .patch< + ApiData + >(`${environment.apiUrl}/custom_file_metadata_records/${fileGuid}/`, payload) + .pipe(map((response) => MapFileCustomMetadata(response))); + } + + getCitationDownloadLink(fileGuid: string): string { + return `${environment.apiUrl}/custom_file_metadata_records/${fileGuid}/`; + } +} diff --git a/src/app/features/project/files/store/project-files.actions.ts b/src/app/features/project/files/store/project-files.actions.ts new file mode 100644 index 000000000..3e9602f87 --- /dev/null +++ b/src/app/features/project/files/store/project-files.actions.ts @@ -0,0 +1,117 @@ +import { OsfFile } from '@osf/features/project/files/models'; +import { PatchFileMetadata } from '@osf/features/project/files/models/requests/patch-file-metadata.mode'; + +export class GetRootFolderFiles { + static readonly type = '[Project Files] Get Root Folder Files'; + + constructor(public projectId: string) {} +} + +export class GetFiles { + static readonly type = '[Project Files] Get Files'; + + constructor(public filesLink: string) {} +} + +export class SetFilesIsLoading { + static readonly type = '[Project Files] Set Files Loading'; + + constructor(public isLoading: boolean) {} +} + +export class GetFileTarget { + static readonly type = '[Project Files] Get File Target'; + + constructor(public fileGuid: string) {} +} + +export class GetFileMetadata { + static readonly type = '[Project Files] Get File Metadata'; + + constructor(public fileGuid: string) {} +} + +export class GetFileProjectMetadata { + static readonly type = '[Project Files] Get File Project Metadata'; + + constructor(public projectId: string) {} +} + +export class GetMoveFileRootFiles { + static readonly type = '[Project Files] Get Move File Root Files'; + + constructor(public projectId: string) {} +} + +export class GetMoveFileFiles { + static readonly type = '[Project Files] Get Move File Files'; + + constructor(public filesLink: string) {} +} + +export class SetCurrentFolder { + static readonly type = '[Project Files] Set Current Folder'; + + constructor(public folder?: OsfFile) {} +} + +export class SetMoveFileCurrentFolder { + static readonly type = '[Project Files] Set Move File Files'; + + constructor(public folder?: OsfFile) {} +} + +export class CreateFolder { + static readonly type = '[Project Files] Create folder'; + + constructor( + public projectId: string, + public folderName: string, + public parentFolderId?: string + ) {} +} + +export class DeleteEntry { + static readonly type = '[Project Files] Delete entry'; + + constructor( + public projectId: string, + public link: string + ) {} +} +export class RenameEntry { + static readonly type = '[Project Files] Rename entry'; + + constructor( + public projectId: string, + public link: string, + public name: string + ) {} +} + +export class SetSearch { + static readonly type = '[Project Files] Set Search'; + + constructor(public search: string) {} +} + +export class SetSort { + static readonly type = '[Project Files] Set Sort'; + + constructor(public sort: string) {} +} + +export class GetFileProjectContributors { + static readonly type = '[Project Files] Get Projects Contributors'; + + constructor(public projectId: string) {} +} + +export class SetFileMetadata { + static readonly type = '[Project Files] Set File Metadata'; + + constructor( + public payload: PatchFileMetadata, + public fileGuid: string + ) {} +} diff --git a/src/app/features/project/files/store/project-files.model.ts b/src/app/features/project/files/store/project-files.model.ts new file mode 100644 index 000000000..dcc205522 --- /dev/null +++ b/src/app/features/project/files/store/project-files.model.ts @@ -0,0 +1,20 @@ +import { OsfFile } from '@osf/features/project/files/models'; +import { FileProvider } from '@osf/features/project/files/models/data/file-provider.const'; +import { OsfFileCustomMetadata } from '@osf/features/project/files/models/osf-models/file-custom-metafata.model'; +import { OsfFileProjectContributor } from '@osf/features/project/files/models/osf-models/file-project-contributor.model'; +import { OsfProjectMetadata } from '@osf/features/project/files/models/osf-models/project-custom-metadata.model'; +import { AsyncStateModel } from '@shared/models/store'; + +export interface ProjectFilesStateModel { + files: AsyncStateModel; + moveFileFiles: AsyncStateModel; + currentFolder?: OsfFile; + moveFileCurrentFolder?: OsfFile; + search: string; + sort: string; + provider: (typeof FileProvider)[keyof typeof FileProvider]; + openedFile: AsyncStateModel; + fileMetadata: AsyncStateModel; + projectMetadata: AsyncStateModel; + contributors: AsyncStateModel; +} diff --git a/src/app/features/project/files/store/project-files.selectors.ts b/src/app/features/project/files/store/project-files.selectors.ts new file mode 100644 index 000000000..d5fad2aaf --- /dev/null +++ b/src/app/features/project/files/store/project-files.selectors.ts @@ -0,0 +1,62 @@ +import { Selector } from '@ngxs/store'; + +import { OsfFile } from '@osf/features/project/files/models'; +import { OsfFileCustomMetadata } from '@osf/features/project/files/models/osf-models/file-custom-metafata.model'; +import { OsfFileProjectContributor } from '@osf/features/project/files/models/osf-models/file-project-contributor.model'; +import { OsfProjectMetadata } from '@osf/features/project/files/models/osf-models/project-custom-metadata.model'; +import { AsyncStateModel } from '@shared/models'; + +import { ProjectFilesStateModel } from './project-files.model'; +import { ProjectFilesState } from './project-files.state'; + +export class ProjectFilesSelectors { + @Selector([ProjectFilesState]) + static getFiles(state: ProjectFilesStateModel): AsyncStateModel { + return state.files; + } + + @Selector([ProjectFilesState]) + static getMoveFileFiles(state: ProjectFilesStateModel): AsyncStateModel { + return state.moveFileFiles; + } + + @Selector([ProjectFilesState]) + static getCurrentFolder(state: ProjectFilesStateModel): OsfFile | undefined { + return state.currentFolder; + } + + @Selector([ProjectFilesState]) + static getMoveFileCurrentFolder(state: ProjectFilesStateModel): OsfFile | undefined { + return state.moveFileCurrentFolder; + } + + @Selector([ProjectFilesState]) + static getProvider(state: ProjectFilesStateModel): string { + return state.provider; + } + + @Selector([ProjectFilesState]) + static getOpenedFile(state: ProjectFilesStateModel): AsyncStateModel { + return state.openedFile; + } + + @Selector([ProjectFilesState]) + static getFileCustomMetadata(state: ProjectFilesStateModel): AsyncStateModel { + return state.fileMetadata; + } + + @Selector([ProjectFilesState]) + static isFileMetadataLoading(state: ProjectFilesStateModel): boolean { + return state.fileMetadata.isLoading; + } + + @Selector([ProjectFilesState]) + static getProjectMetadata(state: ProjectFilesStateModel): OsfProjectMetadata | null { + return state.projectMetadata.data; + } + + @Selector([ProjectFilesState]) + static getProjectContributors(state: ProjectFilesStateModel): OsfFileProjectContributor[] | null { + return state.contributors.data; + } +} diff --git a/src/app/features/project/files/store/project-files.state.ts b/src/app/features/project/files/store/project-files.state.ts new file mode 100644 index 000000000..a8b5ce1f3 --- /dev/null +++ b/src/app/features/project/files/store/project-files.state.ts @@ -0,0 +1,338 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, forkJoin, switchMap, tap, throwError } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { MapProjectMetadata } from '@osf/features/project/files/mappers'; +import { FileProvider } from '@osf/features/project/files/models/data/file-provider.const'; +import { ProjectFilesService } from '@osf/features/project/files/services/project-files.service'; +import { + CreateFolder, + DeleteEntry, + GetFileMetadata, + GetFileProjectContributors, + GetFileProjectMetadata, + GetFiles, + GetFileTarget, + GetMoveFileFiles, + GetMoveFileRootFiles, + GetRootFolderFiles, + RenameEntry, + SetCurrentFolder, + SetFileMetadata, + SetFilesIsLoading, + SetMoveFileCurrentFolder, + SetSearch, + SetSort, +} from '@osf/features/project/files/store/project-files.actions'; +import { ProjectFilesStateModel } from '@osf/features/project/files/store/project-files.model'; + +@Injectable() +@State({ + name: 'ProjectFilesState', + defaults: { + files: { + data: [], + isLoading: false, + error: null, + }, + moveFileFiles: { + data: [], + isLoading: false, + error: null, + }, + search: '', + sort: 'name', + provider: FileProvider.OsfStorage, + openedFile: { + data: null, + isLoading: false, + error: null, + }, + fileMetadata: { + data: null, + isLoading: false, + error: null, + }, + projectMetadata: { + data: null, + isLoading: false, + error: null, + }, + contributors: { + data: null, + isLoading: false, + error: null, + }, + }, +}) +export class ProjectFilesState { + projectFilesService = inject(ProjectFilesService); + + @Action(GetRootFolderFiles) + getRootFolderFiles(ctx: StateContext, action: GetRootFolderFiles) { + const state = ctx.getState(); + ctx.patchState({ files: { ...state.files, isLoading: true, error: null } }); + + return this.projectFilesService.getRootFolderFiles(action.projectId, state.search, state.sort).pipe( + switchMap((files) => { + return this.projectFilesService.getFolder(files[0].relationships.parentFolderLink).pipe( + tap({ + next: (parentFolder) => { + ctx.patchState({ + files: { + data: [...files], + isLoading: false, + error: null, + }, + currentFolder: parentFolder, + }); + }, + }) + ); + }), + catchError((error) => this.handleError(ctx, 'files', error)) + ); + } + + @Action(GetMoveFileRootFiles) + getMoveFileRootFiles(ctx: StateContext, action: GetMoveFileRootFiles) { + const state = ctx.getState(); + ctx.patchState({ + moveFileFiles: { ...state.moveFileFiles, isLoading: true, error: null }, + }); + + return this.projectFilesService.getRootFolderFiles(action.projectId, '', '').pipe( + tap({ + next: (files) => { + ctx.patchState({ + moveFileFiles: { + data: files, + isLoading: false, + error: null, + }, + }); + }, + }), + catchError((error) => this.handleError(ctx, 'moveFileFiles', error)) + ); + } + + @Action(GetMoveFileFiles) + getMoveFileFiles(ctx: StateContext, action: GetMoveFileFiles) { + const state = ctx.getState(); + ctx.patchState({ + moveFileFiles: { ...state.moveFileFiles, isLoading: true, error: null }, + }); + + return this.projectFilesService.getFiles(action.filesLink, '', '').pipe( + tap({ + next: (files) => { + ctx.patchState({ + moveFileFiles: { + data: files, + isLoading: false, + error: null, + }, + }); + }, + }), + catchError((error) => this.handleError(ctx, 'moveFileFiles', error)) + ); + } + + @Action(GetFiles) + getFiles(ctx: StateContext, action: GetFiles) { + const state = ctx.getState(); + ctx.patchState({ files: { ...state.files, isLoading: true, error: null } }); + + return this.projectFilesService.getFiles(action.filesLink, state.search, state.sort).pipe( + tap({ + next: (files) => { + ctx.patchState({ + files: { + data: files, + isLoading: false, + error: null, + }, + }); + }, + }), + catchError((error) => this.handleError(ctx, 'files', error)) + ); + } + + @Action(SetFilesIsLoading) + setFilesIsLoading(ctx: StateContext, action: SetFilesIsLoading) { + const state = ctx.getState(); + ctx.patchState({ files: { ...state.files, isLoading: action.isLoading, error: null } }); + } + + @Action(SetCurrentFolder) + setSelectedFolder(ctx: StateContext, action: SetCurrentFolder) { + ctx.patchState({ currentFolder: action.folder }); + } + + @Action(SetMoveFileCurrentFolder) + setMoveFileSelectedFolder(ctx: StateContext, action: SetMoveFileCurrentFolder) { + ctx.patchState({ moveFileCurrentFolder: action.folder }); + } + + @Action(CreateFolder) + createFolder(ctx: StateContext, action: CreateFolder) { + return this.projectFilesService.createFolder(action.projectId, action.folderName, action.parentFolderId).pipe( + tap({ + next: () => { + const selectedFolder = ctx.getState().currentFolder; + if (selectedFolder?.relationships.filesLink) { + ctx.dispatch(new GetFiles(selectedFolder?.relationships.filesLink)); + } else { + ctx.dispatch(new GetRootFolderFiles(action.projectId)); + } + }, + }) + ); + } + + @Action(DeleteEntry) + deleteEntry(ctx: StateContext, action: DeleteEntry) { + return this.projectFilesService.deleteEntry(action.link).pipe( + tap({ + next: () => { + const selectedFolder = ctx.getState().currentFolder; + if (selectedFolder?.relationships.filesLink) { + ctx.dispatch(new GetFiles(selectedFolder?.relationships.filesLink)); + } else { + ctx.dispatch(new GetRootFolderFiles(action.projectId)); + } + }, + }) + ); + } + + @Action(RenameEntry) + renameEntry(ctx: StateContext, action: RenameEntry) { + return this.projectFilesService.renameEntry(action.link, action.name).pipe( + tap({ + next: () => { + const selectedFolder = ctx.getState().currentFolder; + if (selectedFolder?.relationships.filesLink) { + ctx.dispatch(new GetFiles(selectedFolder?.relationships.filesLink)); + } else { + ctx.dispatch(new GetRootFolderFiles(action.projectId)); + } + }, + }) + ); + } + + @Action(SetSearch) + setSearch(ctx: StateContext, action: SetSearch) { + ctx.patchState({ search: action.search }); + } + + @Action(SetSort) + setSort(ctx: StateContext, action: SetSort) { + ctx.patchState({ sort: action.sort }); + } + + @Action(GetFileTarget) + getFilesTarget(ctx: StateContext, action: GetFileTarget) { + const state = ctx.getState(); + ctx.patchState({ openedFile: { ...state.openedFile, isLoading: true, error: null } }); + + return this.projectFilesService.getFileTarget(action.fileGuid).pipe( + tap({ + next: (file) => { + ctx.patchState({ openedFile: { data: file, isLoading: false, error: null } }); + }, + }), + catchError((error) => this.handleError(ctx, 'openedFile', error)) + ); + } + + @Action(GetFileMetadata) + getFileMetadata(ctx: StateContext, action: GetFileMetadata) { + const state = ctx.getState(); + ctx.patchState({ fileMetadata: { ...state.fileMetadata, isLoading: true, error: null } }); + + return this.projectFilesService.getFileMetadata(action.fileGuid).pipe( + tap({ + next: (metadata) => { + ctx.patchState({ fileMetadata: { data: metadata, isLoading: false, error: null } }); + }, + }), + catchError((error) => this.handleError(ctx, 'fileMetadata', error)) + ); + } + + @Action(SetFileMetadata) + setFileMetadata(ctx: StateContext, action: SetFileMetadata) { + const state = ctx.getState(); + ctx.patchState({ fileMetadata: { ...state.fileMetadata, isLoading: true, error: null } }); + + return this.projectFilesService.patchFileMetadata(action.payload, action.fileGuid).pipe( + tap({ + next: (fileMetadata) => { + if (fileMetadata.id) { + ctx.patchState({ fileMetadata: { data: fileMetadata, isLoading: false, error: null } }); + } + }, + }), + catchError((error) => this.handleError(ctx, 'fileMetadata', error)) + ); + } + + @Action(GetFileProjectMetadata) + getFileProjectMetadata(ctx: StateContext, action: GetFileProjectMetadata) { + const state = ctx.getState(); + ctx.patchState({ projectMetadata: { ...state.projectMetadata, isLoading: true, error: null } }); + + forkJoin({ + projectShortInfo: this.projectFilesService.getProjectShortInfo(action.projectId), + projectMetadata: this.projectFilesService.getProjectCustomMetadata(action.projectId), + }) + .pipe(catchError((error) => this.handleError(ctx, 'projectMetadata', error))) + .subscribe((results) => { + const projectMetadata = MapProjectMetadata(results.projectShortInfo, results.projectMetadata); + ctx.patchState({ + projectMetadata: { + data: projectMetadata, + isLoading: false, + error: null, + }, + }); + }); + } + + @Action(GetFileProjectContributors) + getFileProjectContributors(ctx: StateContext, action: GetFileProjectContributors) { + const state = ctx.getState(); + ctx.patchState({ contributors: { ...state.contributors, isLoading: true, error: null } }); + + return this.projectFilesService.getProjectContributors(action.projectId).pipe( + tap({ + next: (contributors) => { + ctx.patchState({ contributors: { data: contributors, isLoading: true, error: null } }); + }, + }), + catchError((error) => this.handleError(ctx, 'contributors', error)) + ); + } + + private handleError( + ctx: StateContext, + section: 'files' | 'moveFileFiles' | 'openedFile' | 'fileMetadata' | 'projectMetadata' | 'contributors', + error: Error + ) { + ctx.patchState({ + [section]: { + ...ctx.getState()[section], + isLoading: false, + error: error.message, + }, + }); + return throwError(() => error); + } +} diff --git a/src/app/features/search/models/raw-models/index-card-search.model.ts b/src/app/features/search/models/raw-models/index-card-search.model.ts index c7b8fb741..fc37d736f 100644 --- a/src/app/features/search/models/raw-models/index-card-search.model.ts +++ b/src/app/features/search/models/raw-models/index-card-search.model.ts @@ -21,5 +21,5 @@ export type IndexCardSearch = JsonApiResponse< }; }; }, - ApiData<{ resourceMetadata: ResourceItem }, null, null>[] + ApiData<{ resourceMetadata: ResourceItem }, null, null, null>[] >; diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.ts b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts index 54cc21de9..b8c1a1c0d 100644 --- a/src/app/features/settings/account-settings/components/change-password/change-password.component.ts +++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts @@ -104,7 +104,6 @@ export class ChangePasswordComponent implements OnInit { }); }, error: (error: HttpErrorResponse) => { - console.error(error); if (error.error?.errors?.[0]?.detail) { this.errorMessage.set(error.error.errors[0].detail); } else { diff --git a/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts b/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts index 286e9fda4..d507d9b9d 100644 --- a/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts +++ b/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts @@ -2,7 +2,7 @@ import { ApiData } from '@osf/core/models'; import { AccountSettings, AccountSettingsResponse } from '../models'; -export function MapAccountSettings(data: ApiData): AccountSettings { +export function MapAccountSettings(data: ApiData): AccountSettings { return { twoFactorEnabled: data.attributes.two_factor_enabled, twoFactorConfirmed: data.attributes.two_factor_confirmed, diff --git a/src/app/features/settings/account-settings/mappers/emails.mapper.ts b/src/app/features/settings/account-settings/mappers/emails.mapper.ts index 012cf565e..e0a9849cb 100644 --- a/src/app/features/settings/account-settings/mappers/emails.mapper.ts +++ b/src/app/features/settings/account-settings/mappers/emails.mapper.ts @@ -2,7 +2,7 @@ import { ApiData } from '@osf/core/models'; import { AccountEmail, AccountEmailResponse } from '../models'; -export function MapEmails(emails: ApiData[]): AccountEmail[] { +export function MapEmails(emails: ApiData[]): AccountEmail[] { const accountEmails: AccountEmail[] = []; emails.forEach((email) => { accountEmails.push(MapEmail(email)); @@ -10,7 +10,7 @@ export function MapEmails(emails: ApiData[]): return accountEmails; } -export function MapEmail(email: ApiData): AccountEmail { +export function MapEmail(email: ApiData): AccountEmail { return { id: email.id, emailAddress: email.attributes.email_address, diff --git a/src/app/features/settings/account-settings/mappers/external-identities.mapper.ts b/src/app/features/settings/account-settings/mappers/external-identities.mapper.ts index 8abaf4ea7..6f6b75ac1 100644 --- a/src/app/features/settings/account-settings/mappers/external-identities.mapper.ts +++ b/src/app/features/settings/account-settings/mappers/external-identities.mapper.ts @@ -2,7 +2,7 @@ import { ApiData } from '@osf/core/models'; import { ExternalIdentity, ExternalIdentityResponse } from '../models'; -export function MapExternalIdentities(data: ApiData[]): ExternalIdentity[] { +export function MapExternalIdentities(data: ApiData[]): ExternalIdentity[] { const identities: ExternalIdentity[] = []; for (const item of data) { identities.push({ diff --git a/src/app/features/settings/account-settings/mappers/regions.mapper.ts b/src/app/features/settings/account-settings/mappers/regions.mapper.ts index 0a863e69b..5351eff7d 100644 --- a/src/app/features/settings/account-settings/mappers/regions.mapper.ts +++ b/src/app/features/settings/account-settings/mappers/regions.mapper.ts @@ -2,7 +2,7 @@ import { ApiData } from '@osf/core/models'; import { Region } from '../models'; -export function MapRegions(data: ApiData<{ name: string }, null, null>[]): Region[] { +export function MapRegions(data: ApiData<{ name: string }, null, null, null>[]): Region[] { const regions: Region[] = []; for (const region of data) { regions.push(MapRegion(region)); @@ -11,7 +11,7 @@ export function MapRegions(data: ApiData<{ name: string }, null, null>[]): Regio return regions; } -export function MapRegion(data: ApiData<{ name: string }, null, null>): Region { +export function MapRegion(data: ApiData<{ name: string }, null, null, null>): Region { return { id: data.id, name: data.attributes.name, diff --git a/src/app/features/settings/account-settings/models/responses/get-account-settings-response.model.ts b/src/app/features/settings/account-settings/models/responses/get-account-settings-response.model.ts index d04f90c48..9fccab52a 100644 --- a/src/app/features/settings/account-settings/models/responses/get-account-settings-response.model.ts +++ b/src/app/features/settings/account-settings/models/responses/get-account-settings-response.model.ts @@ -1,6 +1,6 @@ import { ApiData } from '@osf/core/models'; -export type GetAccountSettingsResponse = ApiData; +export type GetAccountSettingsResponse = ApiData; export interface AccountSettingsResponse { two_factor_enabled: boolean; diff --git a/src/app/features/settings/account-settings/models/responses/get-email-response.model.ts b/src/app/features/settings/account-settings/models/responses/get-email-response.model.ts index 3623e6b8f..b850020cc 100644 --- a/src/app/features/settings/account-settings/models/responses/get-email-response.model.ts +++ b/src/app/features/settings/account-settings/models/responses/get-email-response.model.ts @@ -2,4 +2,4 @@ import { ApiData, JsonApiResponse } from '@osf/core/models'; import { AccountEmailResponse } from './list-emails.model'; -export type GetEmailResponse = JsonApiResponse, null>; +export type GetEmailResponse = JsonApiResponse, null>; diff --git a/src/app/features/settings/account-settings/models/responses/get-regions-response.model.ts b/src/app/features/settings/account-settings/models/responses/get-regions-response.model.ts index 3d5d3ca14..6247c3a23 100644 --- a/src/app/features/settings/account-settings/models/responses/get-regions-response.model.ts +++ b/src/app/features/settings/account-settings/models/responses/get-regions-response.model.ts @@ -1,4 +1,4 @@ import { ApiData, JsonApiResponse } from '@osf/core/models'; -export type GetRegionsResponse = JsonApiResponse[], null>; -export type GetRegionResponse = JsonApiResponse, null>; +export type GetRegionsResponse = JsonApiResponse[], null>; +export type GetRegionResponse = JsonApiResponse, null>; diff --git a/src/app/features/settings/account-settings/models/responses/list-emails.model.ts b/src/app/features/settings/account-settings/models/responses/list-emails.model.ts index bf663bb4b..867ed5c22 100644 --- a/src/app/features/settings/account-settings/models/responses/list-emails.model.ts +++ b/src/app/features/settings/account-settings/models/responses/list-emails.model.ts @@ -1,6 +1,6 @@ import { ApiData, JsonApiResponse } from '@osf/core/models'; -export type ListEmailsResponse = JsonApiResponse[], null>; +export type ListEmailsResponse = JsonApiResponse[], null>; export interface AccountEmailResponse { email_address: string; diff --git a/src/app/features/settings/account-settings/models/responses/list-identities-response.model.ts b/src/app/features/settings/account-settings/models/responses/list-identities-response.model.ts index 12a47de30..9084f5169 100644 --- a/src/app/features/settings/account-settings/models/responses/list-identities-response.model.ts +++ b/src/app/features/settings/account-settings/models/responses/list-identities-response.model.ts @@ -1,6 +1,6 @@ import { ApiData, JsonApiResponse } from '@osf/core/models'; -export type ListIdentitiesResponse = JsonApiResponse[], null>; +export type ListIdentitiesResponse = JsonApiResponse[], null>; export interface ExternalIdentityResponse { external_id: string; diff --git a/src/app/features/settings/account-settings/services/account-settings.service.ts b/src/app/features/settings/account-settings/services/account-settings.service.ts index 87414c3b0..6c3c88820 100644 --- a/src/app/features/settings/account-settings/services/account-settings.service.ts +++ b/src/app/features/settings/account-settings/services/account-settings.service.ts @@ -81,7 +81,7 @@ export class AccountSettingsService { return this.#jsonApiService .post< - ApiData + ApiData >(`${environment.apiUrl}/users/${this.#currentUser()?.id}/settings/emails/`, body) .pipe(map((response) => MapEmail(response))); } @@ -105,7 +105,7 @@ export class AccountSettingsService { return this.#jsonApiService .patch< - ApiData + ApiData >(`${environment.apiUrl}/users/${userId}/settings/emails/${emailId}/`, body) .pipe(map((response) => MapEmail(response))); } @@ -123,7 +123,7 @@ export class AccountSettingsService { return this.#jsonApiService .patch< - ApiData + ApiData >(`${environment.apiUrl}/users/${this.#currentUser()?.id}/settings/emails/${emailId}/`, body) .pipe(map((response) => MapEmail(response))); } diff --git a/src/app/shared/components/loading-spinner/loading-spinner.component.scss b/src/app/shared/components/loading-spinner/loading-spinner.component.scss index 823f37703..ecc832c11 100644 --- a/src/app/shared/components/loading-spinner/loading-spinner.component.scss +++ b/src/app/shared/components/loading-spinner/loading-spinner.component.scss @@ -7,5 +7,5 @@ .spinner { width: 100%; - height: 50%; + height: 100%; } diff --git a/src/app/shared/components/sub-header/sub-header.component.html b/src/app/shared/components/sub-header/sub-header.component.html index ceaf9c205..b5bce232e 100644 --- a/src/app/shared/components/sub-header/sub-header.component.html +++ b/src/app/shared/components/sub-header/sub-header.component.html @@ -4,7 +4,11 @@ @if (icon()) { } -

{{ title() }}

+ @if (isLoading()) { + + } @else { +

{{ title() }}

+ }
@if (showButton()) {
diff --git a/src/app/shared/components/sub-header/sub-header.component.ts b/src/app/shared/components/sub-header/sub-header.component.ts index 81ae282f2..8a2bc3f11 100644 --- a/src/app/shared/components/sub-header/sub-header.component.ts +++ b/src/app/shared/components/sub-header/sub-header.component.ts @@ -1,4 +1,5 @@ import { Button } from 'primeng/button'; +import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -7,7 +8,7 @@ import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; @Component({ selector: 'osf-sub-header', - imports: [Button], + imports: [Button, Skeleton], templateUrl: './sub-header.component.html', styleUrl: './sub-header.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -18,6 +19,7 @@ export class SubHeaderComponent { title = input(''); icon = input(''); description = input(''); + isLoading = input(false); buttonClick = output(); #isXSmall$ = inject(IS_XSMALL); isXSmall = toSignal(this.#isXSmall$); diff --git a/src/app/shared/models/resource-card/user-counts-response.model.ts b/src/app/shared/models/resource-card/user-counts-response.model.ts index 8951438c6..ab07835d3 100644 --- a/src/app/shared/models/resource-card/user-counts-response.model.ts +++ b/src/app/shared/models/resource-card/user-counts-response.model.ts @@ -35,7 +35,8 @@ export type UserCountsResponse = JsonApiResponse< }; }; }; - } + }, + null >, null >; diff --git a/src/app/shared/services/filters-options.service.ts b/src/app/shared/services/filters-options.service.ts index a8a28cc1f..f4e5c4263 100644 --- a/src/app/shared/services/filters-options.service.ts +++ b/src/app/shared/services/filters-options.service.ts @@ -61,11 +61,11 @@ export class FiltersOptionsService { return this.#jsonApiService .get< - JsonApiResponse[]> + JsonApiResponse[]> >(`${environment.shareDomainUrl}/index-value-search`, fullParams) .pipe( map((response) => { - const included = (response?.included ?? []) as ApiData<{ resourceMetadata: CreatorItem }, null, null>[]; + const included = (response?.included ?? []) as ApiData<{ resourceMetadata: CreatorItem }, null, null, null>[]; return included .filter((item) => item.type === 'index-card') .map((item) => MapCreators(item.attributes.resourceMetadata)); diff --git a/src/app/shared/utils/format-file-size.helper.ts b/src/app/shared/utils/format-file-size.helper.ts new file mode 100644 index 000000000..3a65fcdd5 --- /dev/null +++ b/src/app/shared/utils/format-file-size.helper.ts @@ -0,0 +1,11 @@ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 ** 2) { + return `${(bytes / 1024).toFixed(1)} kB`; + } else if (bytes < 1024 ** 3) { + return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + } else { + return `${(bytes / 1024 ** 3).toFixed(1)} GB`; + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index cf36b64ef..3dd736b3c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -551,6 +551,90 @@ "lastUpdated": "Last Updated:", "description": "Description:", "openResources": "Open Resources" + } + }, + "files": { + "title": "Files", + "storageLocation": "OSF Storage", + "searchPlaceholder": "Search your projects", + "sort": { + "placeholder": "Sort", + "nameAZ": "Name: A-Z", + "nameZA": "Name: Z-A", + "lastModifiedOldest": "Last modified: oldest to newest", + "lastModifiedNewest": "Last modified: newest to oldest" + }, + "actions": { + "downloadAsZip": "Download As Zip", + "createFolder": "Create Folder", + "uploadFile": "Upload File" + }, + "dialogs": { + "uploadFile": { + "title": "Upload file" + }, + "createFolder": { + "title": "Create folder", + "folderName": "New folder name", + "folderNamePlaceholder": "Please enter a folder name", + "buttons": { + "cancel": "Cancel", + "save": "Save" + } + }, + "renameFile": { + "title": "Rename file", + "renameLabel": "Please rename the file", + "buttons": { + "cancel": "Cancel", + "save": "Save" + } + } + }, + "emptyState": "This folder is empty", + "menu": { + "download": "Download", + "copy": "Copy", + "move": "Move", + "rename": "Rename", + "delete": "Delete" + }, + "detail": { + "backToList": "Back to list of files", + "fileMetadata": { + "title": "File Metadata", + "edit": "Edit", + "fields": { + "title": "Title", + "description": "Description", + "resourceType": "Resource Type", + "resourceLanguage": "Resource Language" + } + }, + "projectMetadata": { + "title": "Project Metadata", + "edit": "Edit", + "fields": { + "funder": "Funder", + "awardTitle": "Award title", + "awardNumber": "Award number", + "awardUri": "Award URI", + "title": "Title", + "description": "Description", + "resourceType": "Resource type", + "resourceLanguage": "Resource language", + "dateCreated": "Date created", + "dateModified": "Date modified", + "contributors": "Contributors" + } + }, + "editDialog": { + "title": "Edit File Metadata", + "buttons": { + "cancel": "Cancel", + "save": "Save" + } + } } } }, @@ -1068,4 +1152,4 @@ "minLength": "The field must be at least {{length}} characters.", "invalidInput": "Invalid input." } -} +} \ No newline at end of file diff --git a/src/assets/styles/_base.scss b/src/assets/styles/_base.scss index ca84fddc4..c8835472d 100644 --- a/src/assets/styles/_base.scss +++ b/src/assets/styles/_base.scss @@ -49,6 +49,12 @@ list-style: none; } + .inside-list { + li { + list-style: inside; + } + } + a, a:visited { text-decoration: none; diff --git a/src/assets/styles/_common.scss b/src/assets/styles/_common.scss index 2da873c08..7642bd4ab 100644 --- a/src/assets/styles/_common.scss +++ b/src/assets/styles/_common.scss @@ -78,3 +78,8 @@ color: var.$pr-blue-3; } } +// ------------------------- Text styles ------------------------- + +.text-no-transform { + text-transform: none; +} diff --git a/src/assets/styles/overrides/button.scss b/src/assets/styles/overrides/button.scss index 51b267d27..8e8e15b4a 100644 --- a/src/assets/styles/overrides/button.scss +++ b/src/assets/styles/overrides/button.scss @@ -292,3 +292,8 @@ .secondary-add-btn .p-button.p-button-secondary { --p-button-secondary-color: var(--dark-blue-1); } + +.no-padding-button .p-button { + background: var.$white; + padding: 0; +} diff --git a/src/assets/styles/overrides/spinner.scss b/src/assets/styles/overrides/spinner.scss index 792ba84e3..974613e4f 100644 --- a/src/assets/styles/overrides/spinner.scss +++ b/src/assets/styles/overrides/spinner.scss @@ -3,3 +3,10 @@ .p-progressspinner-circle { stroke: var.$pr-blue-1 !important; } + +.p-progressspinner { + width: 100%; + height: 100%; + max-width: 100px; + max-height: 100px; +} diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 861bd440f..420a2ce4f 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -4,4 +4,5 @@ export const environment = { apiDomainUrl: 'https://api.staging4.osf.io', shareDomainUrl: 'https://staging-share.osf.io/trove', addonsApiUrl: 'https://addons.staging4.osf.io/v1', + fileApiUrl: 'https://files.us.staging4.osf.io/v1', }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 861bd440f..420a2ce4f 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -4,4 +4,5 @@ export const environment = { apiDomainUrl: 'https://api.staging4.osf.io', shareDomainUrl: 'https://staging-share.osf.io/trove', addonsApiUrl: 'https://addons.staging4.osf.io/v1', + fileApiUrl: 'https://files.us.staging4.osf.io/v1', }; From b35769b612ccb74f2904b7c579e22442a4a35990 Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Tue, 3 Jun 2025 14:09:40 +0300 Subject: [PATCH 2/2] fix(files-api): general fixes --- .../file-metadata.component.html | 122 +++++++++ .../file-metadata.component.scss | 1 + .../file-metadata/file-metadata.component.ts | 94 +++++++ .../project-metadata.component.html | 105 ++++++++ .../project-metadata.component.scss | 1 + .../project-metadata.component.ts | 24 ++ .../constants/files-details.constants.ts | 38 +++ .../file-detail/file-detail.component.html | 232 +----------------- .../file-detail/file-detail.component.scss | 21 +- .../file-detail/file-detail.component.ts | 111 +-------- .../move-file-dialog.component.html | 25 +- .../move-file-dialog.component.scss | 125 ++++++---- .../move-file-dialog.component.ts | 53 ++-- .../features/project/files/constants/index.ts | 1 + .../constants/project-files.constants.ts | 28 +++ .../mappers/file-custom-metadata.mapper.ts | 3 +- .../files/mappers/project-metadata.mapper.ts | 9 +- .../features/project/files/models/index.ts | 4 +- ...model.ts => file-custom-metadata.model.ts} | 0 ...a.mode.ts => patch-file-metadata.model.ts} | 0 .../files/project-files.component.html | 80 +++--- .../files/project-files.component.scss | 178 +++++++------- .../project/files/project-files.component.ts | 197 ++++++++------- .../features/project/files/services/index.ts | 1 + .../files/services/project-files.service.ts | 7 +- src/app/features/project/files/store/index.ts | 3 + .../files/store/project-files.actions.ts | 3 +- .../files/store/project-files.model.ts | 12 +- .../files/store/project-files.selectors.ts | 2 +- src/app/shared/pipes/file-size.pipe.ts | 20 ++ src/app/shared/pipes/index.ts | 1 + .../shared/utils/format-file-size.helper.ts | 11 - src/assets/i18n/en.json | 40 +-- src/assets/styles/_base.scss | 6 - 34 files changed, 858 insertions(+), 700 deletions(-) create mode 100644 src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html create mode 100644 src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.scss create mode 100644 src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.ts create mode 100644 src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.html create mode 100644 src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.scss create mode 100644 src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.ts create mode 100644 src/app/features/project/files/components/file-detail/constants/files-details.constants.ts create mode 100644 src/app/features/project/files/constants/index.ts create mode 100644 src/app/features/project/files/constants/project-files.constants.ts rename src/app/features/project/files/models/osf-models/{file-custom-metafata.model.ts => file-custom-metadata.model.ts} (100%) rename src/app/features/project/files/models/requests/{patch-file-metadata.mode.ts => patch-file-metadata.model.ts} (100%) create mode 100644 src/app/features/project/files/services/index.ts create mode 100644 src/app/features/project/files/store/index.ts create mode 100644 src/app/shared/pipes/file-size.pipe.ts delete mode 100644 src/app/shared/utils/format-file-size.helper.ts diff --git a/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html b/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html new file mode 100644 index 000000000..005cdd7e5 --- /dev/null +++ b/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html @@ -0,0 +1,122 @@ + + + +
+
+

{{ 'project.files.detail.fileMetadata.fields.title' | translate }}

+ +
+
+

{{ 'project.files.detail.fileMetadata.fields.description' | translate }}

+ +
+
+

{{ 'project.files.detail.fileMetadata.fields.resourceType' | translate }}

+ +
+
+

{{ 'project.files.detail.fileMetadata.fields.resourceLanguage' | translate }}

+ +
+ +
+ + +
+
+
diff --git a/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.scss b/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.scss new file mode 100644 index 000000000..f1df301a6 --- /dev/null +++ b/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.scss @@ -0,0 +1 @@ +@use "../../file-detail.component.scss"; diff --git a/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.ts b/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.ts new file mode 100644 index 000000000..d733d1cbe --- /dev/null +++ b/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.ts @@ -0,0 +1,94 @@ +import { select, Store } from '@ngxs/store'; + +import { TranslateModule } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Dialog } from 'primeng/dialog'; +import { InputText } from 'primeng/inputtext'; +import { Select } from 'primeng/select'; +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { ProjectFilesSelectors, SetFileMetadata } from '@osf/features/project/files/store'; + +import { LANGUAGES, RESOURCE_TYPES } from '../../constants/files-details.constants'; + +@Component({ + selector: 'osf-file-metadata', + standalone: true, + imports: [Button, Dialog, InputText, Select, FormsModule, ReactiveFormsModule, Skeleton, TranslateModule], + templateUrl: './file-metadata.component.html', + styleUrl: './file-metadata.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FileMetadataComponent { + readonly store = inject(Store); + readonly destroyRef = inject(DestroyRef); + readonly fb = inject(FormBuilder); + + fileMetadata = select(ProjectFilesSelectors.getFileCustomMetadata); + isIframeLoading = true; + editFileMetadataVisible = false; + + fileMetadataForm = new FormGroup({ + title: new FormControl(null), + description: new FormControl(null), + resourceType: new FormControl(null), + resourceLanguage: new FormControl(null), + }); + + protected readonly resourceTypes = RESOURCE_TYPES; + protected readonly languages = LANGUAGES; + + get titleControl(): FormControl { + return this.fileMetadataForm.get('title') as FormControl; + } + + get descriptionControl(): FormControl { + return this.fileMetadataForm.get('description') as FormControl; + } + + get resourceTypeControl(): FormControl { + return this.fileMetadataForm.get('resourceType') as FormControl; + } + + get resourceLanguageControl(): FormControl { + return this.fileMetadataForm.get('resourceLanguage') as FormControl; + } + + constructor() { + toObservable(this.fileMetadata) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((metadata) => { + if (metadata.data) { + this.fileMetadataForm.patchValue({ + title: metadata.data.title, + description: metadata.data.description, + resourceType: metadata.data.resourceTypeGeneral, + resourceLanguage: metadata.data.language, + }); + } + }); + } + + setFileMetadata() { + if (this.fileMetadataForm.valid) { + const formValues = { + title: this.fileMetadataForm.get('title')?.value ?? null, + description: this.fileMetadataForm.get('description')?.value ?? null, + resource_type_general: this.fileMetadataForm.get('resourceType')?.value ?? null, + language: this.fileMetadataForm.get('resourceLanguage')?.value ?? null, + }; + + const fileId = this.fileMetadata().data?.id; + if (fileId) { + this.store.dispatch(new SetFileMetadata(formValues, fileId)); + } + + this.editFileMetadataVisible = false; + } + } +} diff --git a/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.html b/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.html new file mode 100644 index 000000000..063c395b9 --- /dev/null +++ b/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.html @@ -0,0 +1,105 @@ + diff --git a/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.scss b/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.scss new file mode 100644 index 000000000..f1df301a6 --- /dev/null +++ b/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.scss @@ -0,0 +1 @@ +@use "../../file-detail.component.scss"; diff --git a/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.ts b/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.ts new file mode 100644 index 000000000..f7832a276 --- /dev/null +++ b/src/app/features/project/files/components/file-detail/components/project-metadata/project-metadata.component.ts @@ -0,0 +1,24 @@ +import { select, Store } from '@ngxs/store'; + +import { TranslateModule } from '@ngx-translate/core'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; + +import { ProjectFilesSelectors } from '@osf/features/project/files/store'; + +@Component({ + selector: 'osf-project-metadata', + standalone: true, + imports: [DatePipe, TranslateModule], + templateUrl: './project-metadata.component.html', + styleUrl: './project-metadata.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectMetadataComponent { + readonly store = inject(Store); + readonly destroyRef = inject(DestroyRef); + + projectMetadata = select(ProjectFilesSelectors.getProjectMetadata); + contributors = select(ProjectFilesSelectors.getProjectContributors); +} diff --git a/src/app/features/project/files/components/file-detail/constants/files-details.constants.ts b/src/app/features/project/files/components/file-detail/constants/files-details.constants.ts new file mode 100644 index 000000000..b5bba09e7 --- /dev/null +++ b/src/app/features/project/files/components/file-detail/constants/files-details.constants.ts @@ -0,0 +1,38 @@ +// [KP] TODO: Figure out where to get this options + +export const RESOURCE_TYPES = [ + { value: 'Audiovisual' }, + { value: 'Book' }, + { value: 'BookChapter' }, + { value: 'Collection' }, + { value: 'ComputationalNotebook' }, + { value: 'ConferencePaper' }, + { value: 'ConferenceProceeding' }, + { value: 'DataPaper' }, + { value: 'Dataset' }, + { value: 'Dissertation' }, + { value: 'Event' }, + { value: 'Image' }, + { value: 'Instrument' }, + { value: 'InteractiveResource' }, + { value: 'Journal' }, + { value: 'JournalArticle' }, + { value: 'Model' }, + { value: 'OutputManagementPlan' }, + { value: 'PeerReview' }, + { value: 'PhysicalObject' }, + { value: 'Preprint' }, + { value: 'Report' }, + { value: 'Service' }, + { value: 'Software' }, + { value: 'Standard' }, + { value: 'StudyRegistration' }, + { value: 'Text' }, + { value: 'Workflow' }, + { value: 'Other' }, +]; + +export const LANGUAGES = [ + { value: 'eng', label: 'English' }, + { value: 'ukr', label: 'Ukrainian' }, +]; diff --git a/src/app/features/project/files/components/file-detail/file-detail.component.html b/src/app/features/project/files/components/file-detail/file-detail.component.html index bbb3d7492..609230805 100644 --- a/src/app/features/project/files/components/file-detail/file-detail.component.html +++ b/src/app/features/project/files/components/file-detail/file-detail.component.html @@ -23,240 +23,14 @@ > } @if (isIframeLoading) { -
+
}
- - - + +
- - -
-
-

{{ 'project.files.detail.fileMetadata.fields.title' | translate }}

- -
-
-

{{ 'project.files.detail.fileMetadata.fields.description' | translate }}

- -
-
-

{{ 'project.files.detail.fileMetadata.fields.resourceType' | translate }}

- -
-
-

{{ 'project.files.detail.fileMetadata.fields.resourceLanguage' | translate }}

- -
- -
- - -
-
-
diff --git a/src/app/features/project/files/components/file-detail/file-detail.component.scss b/src/app/features/project/files/components/file-detail/file-detail.component.scss index ff6435464..e4d1e4516 100644 --- a/src/app/features/project/files/components/file-detail/file-detail.component.scss +++ b/src/app/features/project/files/components/file-detail/file-detail.component.scss @@ -1,11 +1,18 @@ @use "assets/styles/variables" as var; -.back-navigation { - color: var(--blue-2); -} +:host { + .back-navigation { + color: var(--blue-2); + } + + .metadata { + color: var(--dark-blue-1); + border: 1px solid var(--grey-2); + border-radius: 12px; + } -.metadata { - color: var(--dark-blue-1); - border: 1px solid var(--grey-2); - border-radius: 12px; + .spinner-container { + width: 60%; + height: 100%; + } } diff --git a/src/app/features/project/files/components/file-detail/file-detail.component.ts b/src/app/features/project/files/components/file-detail/file-detail.component.ts index 63dfafbd1..8dfbdf961 100644 --- a/src/app/features/project/files/components/file-detail/file-detail.component.ts +++ b/src/app/features/project/files/components/file-detail/file-detail.component.ts @@ -2,16 +2,8 @@ import { select, Store } from '@ngxs/store'; import { TranslateModule } from '@ngx-translate/core'; -import { Button } from 'primeng/button'; -import { Dialog } from 'primeng/dialog'; -import { InputText } from 'primeng/inputtext'; -import { Select } from 'primeng/select'; -import { Skeleton } from 'primeng/skeleton'; - -import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, DestroyRef, HostBinding, inject } from '@angular/core'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; -import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -20,26 +12,22 @@ import { GetFileProjectContributors, GetFileProjectMetadata, GetFileTarget, - SetFileMetadata, -} from '@osf/features/project/files/store/project-files.actions'; -import { ProjectFilesSelectors } from '@osf/features/project/files/store/project-files.selectors'; + ProjectFilesSelectors, +} from '@osf/features/project/files/store'; import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { FileMetadataComponent } from './components/file-metadata/file-metadata.component'; +import { ProjectMetadataComponent } from './components/project-metadata/project-metadata.component'; + @Component({ selector: 'osf-file-detail', imports: [ SubHeaderComponent, RouterLink, - Button, LoadingSpinnerComponent, - DatePipe, - Dialog, - InputText, - Select, - FormsModule, - ReactiveFormsModule, - Skeleton, TranslateModule, + FileMetadataComponent, + ProjectMetadataComponent, ], templateUrl: './file-detail.component.html', styleUrl: './file-detail.component.scss', @@ -52,65 +40,13 @@ export class FileDetailComponent { readonly route = inject(ActivatedRoute); readonly destroyRef = inject(DestroyRef); readonly sanitizer = inject(DomSanitizer); - readonly fb = inject(FormBuilder); file = select(ProjectFilesSelectors.getOpenedFile); - fileMetadata = select(ProjectFilesSelectors.getFileCustomMetadata); - projectMetadata = select(ProjectFilesSelectors.getProjectMetadata); - contributors = select(ProjectFilesSelectors.getProjectContributors); safeLink: SafeResourceUrl | null = null; isIframeLoading = true; - editFileMetadataVisible = false; - - fileMetadataForm = new FormGroup({ - title: new FormControl(null), - description: new FormControl(null), - resourceType: new FormControl(null), - resourceLanguage: new FormControl(null), - }); - - // TO DO: figure out where to get this options - resourceTypes = [ - { value: 'Audiovisual' }, - { value: 'Book' }, - { value: 'BookChapter' }, - { value: 'Collection' }, - { value: 'ComputationalNotebook' }, - { value: 'ConferencePaper' }, - { value: 'ConferenceProceeding' }, - { value: 'DataPaper' }, - { value: 'Dataset' }, - { value: 'Dissertation' }, - { value: 'Event' }, - { value: 'Image' }, - { value: 'Instrument' }, - { value: 'InteractiveResource' }, - { value: 'Journal' }, - { value: 'JournalArticle' }, - { value: 'Model' }, - { value: 'OutputManagementPlan' }, - { value: 'PeerReview' }, - { value: 'PhysicalObject' }, - { value: 'Preprint' }, - { value: 'Report' }, - { value: 'Service' }, - { value: 'Software' }, - { value: 'Standard' }, - { value: 'StudyRegistration' }, - { value: 'Text' }, - { value: 'Workflow' }, - { value: 'Other' }, - ]; - - // TO DO: figure out where to get this options - languages = [ - { value: 'eng', label: 'English' }, - { value: 'ukr', label: 'Ukrainian' }, - ]; constructor() { - // Subscribe to route parameter changes this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { const guid = params['fileGuid']; this.store.dispatch(new GetFileTarget(guid)).subscribe(() => { @@ -129,36 +65,5 @@ export class FileDetailComponent { this.store.dispatch(new GetFileProjectContributors(projectId)); } }); - - toObservable(this.fileMetadata) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((metadata) => { - if (metadata.data) { - this.fileMetadataForm.patchValue({ - title: metadata.data.title, - description: metadata.data.description, - resourceType: metadata.data.resourceTypeGeneral, - resourceLanguage: metadata.data.language, - }); - } - }); - } - - setFileMetadata() { - if (this.fileMetadataForm.valid) { - const formValues = { - title: this.fileMetadataForm.get('title')?.value ?? null, - description: this.fileMetadataForm.get('description')?.value ?? null, - resource_type_general: this.fileMetadataForm.get('resourceType')?.value ?? null, - language: this.fileMetadataForm.get('resourceLanguage')?.value ?? null, - }; - - const fileId = this.file().data?.id; - if (fileId) { - this.store.dispatch(new SetFileMetadata(formValues, fileId)); - } - - this.editFileMetadataVisible = false; - } } } diff --git a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html index df53f8a19..010b5a3b3 100644 --- a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html @@ -29,13 +29,15 @@

OSF Storage

@for (file of files().data; track $index) {
-
+
@if (file.kind !== 'folder') { - -
{{ file?.name ?? '' }}
+ + } @else if (config.data.file.id === file.id) { - -
{{ file?.name ?? '' }}
+ + } @else { @@ -55,18 +57,23 @@

This folder is empty

}
- + @if (this.config.data.action === 'move') { } @else if (this.config.data.action === 'copy') { } diff --git a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss index 168826298..9e846a4cc 100644 --- a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss @@ -4,74 +4,91 @@ :host { @include mix.flex-column; flex: 1; - gap: 1.5rem; -} + gap: mix.rem(24px); -.files-table { - display: flex; - flex-direction: column; - border: 1px solid var.$grey-2; - border-radius: 8px; - padding: 0 12px; + .files-table { + display: flex; + flex-direction: column; + border: 1px solid var.$grey-2; + border-radius: mix.rem(8px); + padding: 0 mix.rem(12px); - &-row { - color: var.$dark-blue-1; - display: grid; - align-items: center; - grid-template-columns: 3fr 1fr 1fr 1fr 0.5fr; - grid-template-rows: 38px; - border-bottom: 1px solid var.$grey-2; + &-row { + color: var.$dark-blue-1; + display: flex; + align-items: center; + min-height: mix.rem(38px); + border-bottom: 1px solid var.$grey-2; + } + + .table-cell { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + + &-row:last-child { + border-bottom: none; + } } - &-row:last-child { - border-bottom: none; + .filename-link { + cursor: pointer; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; + + &:hover { + text-decoration: underline; + } } -} -.filename-link { - cursor: pointer; - &:hover { - text-decoration: underline; + .parent-folder-link { + cursor: pointer; + display: flex; + gap: mix.rem(5px); } -} -.parent-folder-link { - cursor: pointer; - display: flex; - gap: 5px; -} + .sorting-container { + display: flex; + align-items: center; + border: 1px solid var.$grey-2; + border-radius: mix.rem(8px); + padding: mix.rem(12px); + height: mix.rem(44px); + } -.sorting-container { - display: flex; - align-items: center; - border: 1px solid var.$grey-2; - border-radius: 8px; - padding: 12px; - height: 44px; -} + .outline-button { + font-weight: 600; + display: flex; + gap: mix.rem(8px); + border: 1px solid var.$grey-2; + padding: mix.rem(12px); + border-radius: mix.rem(8px); + height: mix.rem(44px); -.outline-button { - font-weight: 600; - display: flex; - gap: 8px; - border: 1px solid var.$grey-2; - padding: 12px; - border-radius: 8px; - height: 44px; + &.blue { + color: var.$pr-blue-1; + } - &.blue { - color: var.$pr-blue-1; + &.green { + color: var.$green-1; + } } - &.green { - color: var.$green-1; + .filename { + overflow-wrap: anywhere; } -} -.filename { - overflow-wrap: anywhere; -} + .spinner-container { + width: mix.rem(38px); + } -.spinner-container { - width: 38px; + .disabled-icon { + color: var.$grey-1; + } } diff --git a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts index 78072c0ed..5012f1e76 100644 --- a/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts @@ -1,30 +1,33 @@ -import { select, Store } from '@ngxs/store'; +import { createDispatchMap, select, Store } from '@ngxs/store'; + +import { TranslateModule } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { Tooltip } from 'primeng/tooltip'; -import { finalize } from 'rxjs'; +import { finalize, take } from 'rxjs'; import { NgOptimizedImage } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { OsfFile } from '@osf/features/project/files/models'; -import { ProjectFilesService } from '@osf/features/project/files/services/project-files.service'; +import { ProjectFilesService } from '@osf/features/project/files/services'; import { GetFiles, GetMoveFileFiles, GetRootFolderFiles, + ProjectFilesSelectors, SetCurrentFolder, SetFilesIsLoading, SetMoveFileCurrentFolder, -} from '@osf/features/project/files/store/project-files.actions'; -import { ProjectFilesSelectors } from '@osf/features/project/files/store/project-files.selectors'; +} from '@osf/features/project/files/store'; import { LoadingSpinnerComponent } from '@shared/components'; @Component({ selector: 'osf-move-file-dialog', - imports: [Button, LoadingSpinnerComponent, NgOptimizedImage, Tooltip], + imports: [Button, LoadingSpinnerComponent, NgOptimizedImage, Tooltip, TranslateModule], templateUrl: './move-file-dialog.component.html', styleUrl: './move-file-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -34,6 +37,7 @@ export class MoveFileDialogComponent { dialogRef = inject(DynamicDialogRef); projectFilesService = inject(ProjectFilesService); config = inject(DynamicDialogConfig); + destroyRef = inject(DestroyRef); protected readonly files = select(ProjectFilesSelectors.getMoveFileFiles); protected readonly currentFolder = select(ProjectFilesSelectors.getMoveFileCurrentFolder); @@ -42,18 +46,27 @@ export class MoveFileDialogComponent { return this.currentFolder()?.id === this.config.data.file.relationships.parentFolderId; }); + protected readonly dispatch = createDispatchMap({ + getMoveFileFiles: GetMoveFileFiles, + setMoveFileCurrentFolder: SetMoveFileCurrentFolder, + setFilesIsLoading: SetFilesIsLoading, + setCurrentFolder: SetCurrentFolder, + getFiles: GetFiles, + getRootFolderFiles: GetRootFolderFiles, + }); + constructor() { const filesLink = this.currentFolder()?.relationships.filesLink; if (filesLink) { - this.store.dispatch(new GetMoveFileFiles(filesLink)); + this.dispatch.getMoveFileFiles(filesLink); } } openFolder(file: OsfFile) { if (file.kind !== 'folder') return; - this.store.dispatch(new GetMoveFileFiles(file.relationships.filesLink)); - this.store.dispatch(new SetMoveFileCurrentFolder(file)); + this.dispatch.getMoveFileFiles(file.relationships.filesLink); + this.dispatch.setMoveFileCurrentFolder(file); } openParentFolder() { @@ -65,19 +78,20 @@ export class MoveFileDialogComponent { this.projectFilesService .getFolder(currentFolder.relationships.parentFolderLink) .pipe( + take(1), + takeUntilDestroyed(this.destroyRef), finalize(() => { this.isFilesUpdating.set(false); }) ) .subscribe((folder) => { - this.store.dispatch(new SetMoveFileCurrentFolder(folder)); - this.store.dispatch(new GetMoveFileFiles(folder.relationships.filesLink)); + this.dispatch.setMoveFileCurrentFolder(folder); + this.dispatch.getMoveFileFiles(folder.relationships.filesLink); }); } moveFile(): void { let path = this.currentFolder()?.path; - console.log(this.currentFolder()); if (!path) { throw new Error('Path is not specified!.'); @@ -87,23 +101,24 @@ export class MoveFileDialogComponent { path = '/'; } - this.store.dispatch(new SetFilesIsLoading(true)); + this.dispatch.setFilesIsLoading(true); this.projectFilesService .moveFile(this.config.data.file.links.move, path, this.config.data.projectId, this.config.data.action) .pipe( + take(1), + takeUntilDestroyed(this.destroyRef), finalize(() => { - this.store.dispatch(new SetCurrentFolder(this.currentFolder())); - this.store.dispatch(new SetMoveFileCurrentFolder(undefined)); + this.dispatch.setCurrentFolder(this.currentFolder()); + this.dispatch.setMoveFileCurrentFolder(undefined); }) ) .subscribe((file) => { if (file.id) { const filesLink = this.currentFolder()?.relationships.filesLink; - console.log(this.currentFolder()); if (filesLink) { - this.store.dispatch(new GetFiles(filesLink)); + this.dispatch.getFiles(filesLink); } else { - this.store.dispatch(new GetRootFolderFiles(this.config.data.projectId)); + this.dispatch.getRootFolderFiles(this.config.data.projectId); } } }); diff --git a/src/app/features/project/files/constants/index.ts b/src/app/features/project/files/constants/index.ts new file mode 100644 index 000000000..db011ca4d --- /dev/null +++ b/src/app/features/project/files/constants/index.ts @@ -0,0 +1 @@ +export * from './project-files.constants'; diff --git a/src/app/features/project/files/constants/project-files.constants.ts b/src/app/features/project/files/constants/project-files.constants.ts new file mode 100644 index 000000000..9967f023d --- /dev/null +++ b/src/app/features/project/files/constants/project-files.constants.ts @@ -0,0 +1,28 @@ +import { FileMenuItems, FilesSorting } from '../models'; + +export const FILE_MENU_ITEMS = [ + { label: FileMenuItems.Download }, + { label: FileMenuItems.Copy }, + { label: FileMenuItems.Move }, + { label: FileMenuItems.Delete }, + { label: FileMenuItems.Rename }, +]; + +export const FILE_SORT_OPTIONS = [ + { + value: FilesSorting.NameAZ, + label: 'Name: A-Z', + }, + { + value: FilesSorting.NameZA, + label: 'Name: Z-A', + }, + { + value: FilesSorting.LastModifiedOldest, + label: 'Last modified: oldest to newest', + }, + { + value: FilesSorting.LastModifiedNewest, + label: 'Last modified: newest to oldest', + }, +]; diff --git a/src/app/features/project/files/mappers/file-custom-metadata.mapper.ts b/src/app/features/project/files/mappers/file-custom-metadata.mapper.ts index a7a16b190..2611fe4c8 100644 --- a/src/app/features/project/files/mappers/file-custom-metadata.mapper.ts +++ b/src/app/features/project/files/mappers/file-custom-metadata.mapper.ts @@ -1,6 +1,5 @@ import { ApiData } from '@osf/core/models'; -import { OsfFileCustomMetadata } from '@osf/features/project/files/models/osf-models/file-custom-metafata.model'; -import { FileCustomMetadata } from '@osf/features/project/files/models/responses/get-file-metadata-reponse.model'; +import { FileCustomMetadata, OsfFileCustomMetadata } from '@osf/features/project/files/models'; export function MapFileCustomMetadata(data: ApiData): OsfFileCustomMetadata { return { diff --git a/src/app/features/project/files/mappers/project-metadata.mapper.ts b/src/app/features/project/files/mappers/project-metadata.mapper.ts index 7eb0e85f6..caf5a309f 100644 --- a/src/app/features/project/files/mappers/project-metadata.mapper.ts +++ b/src/app/features/project/files/mappers/project-metadata.mapper.ts @@ -1,7 +1,8 @@ -import { GetProjectCustomMetadataResponse } from '@osf/features/project/files/models/responses/get-project-custom-metadata-response.model'; -import { GetProjectShortInfoResponse } from '@osf/features/project/files/models/responses/get-project-short-info-response.model'; - -import { OsfProjectMetadata } from '../models/osf-models/project-custom-metadata.model'; +import { + GetProjectCustomMetadataResponse, + GetProjectShortInfoResponse, + OsfProjectMetadata, +} from '@osf/features/project/files/models'; export function MapProjectMetadata( shortInfo: GetProjectShortInfoResponse, diff --git a/src/app/features/project/files/models/index.ts b/src/app/features/project/files/models/index.ts index 77cb86f7c..b16f0079e 100644 --- a/src/app/features/project/files/models/index.ts +++ b/src/app/features/project/files/models/index.ts @@ -1,5 +1,5 @@ // OSF Models -export * from './osf-models/file-custom-metafata.model'; +export * from './osf-models/file-custom-metadata.model'; export * from './osf-models/file-project-contributor.model'; export * from './osf-models/file-system-entry.model'; export * from './osf-models/file-target.model'; @@ -16,7 +16,7 @@ export * from './responses/get-project-custom-metadata-response.model'; export * from './responses/get-project-short-info-response.model'; // Request Models -export * from './requests/patch-file-metadata.mode'; +export * from './requests/patch-file-metadata.model'; // Constants export * from './data/file-menu-items.const'; diff --git a/src/app/features/project/files/models/osf-models/file-custom-metafata.model.ts b/src/app/features/project/files/models/osf-models/file-custom-metadata.model.ts similarity index 100% rename from src/app/features/project/files/models/osf-models/file-custom-metafata.model.ts rename to src/app/features/project/files/models/osf-models/file-custom-metadata.model.ts diff --git a/src/app/features/project/files/models/requests/patch-file-metadata.mode.ts b/src/app/features/project/files/models/requests/patch-file-metadata.model.ts similarity index 100% rename from src/app/features/project/files/models/requests/patch-file-metadata.mode.ts rename to src/app/features/project/files/models/requests/patch-file-metadata.model.ts diff --git a/src/app/features/project/files/project-files.component.html b/src/app/features/project/files/project-files.component.html index 9987f6848..e68e1d3b6 100644 --- a/src/app/features/project/files/project-files.component.html +++ b/src/app/features/project/files/project-files.component.html @@ -25,8 +25,7 @@
- +
@@ -81,7 +80,7 @@ [header]="'project.files.dialogs.uploadFile.title' | translate" [modal]="true" [(visible)]="fileIsUploading" - [style]="{ width: '30rem' }" + class="upload-dialog" >
{{ fileName() }} @@ -96,29 +95,31 @@ [header]="'project.files.dialogs.createFolder.title' | translate" [modal]="true" [(visible)]="createFolderVisible" - [style]="{ width: '45rem' }" + class="action-dialog" >
- +
+ +
@if (!isFolderCreating) { - + } @else {
@@ -131,21 +132,23 @@ [header]="'project.files.dialogs.renameFile.title' | translate" [modal]="true" [(visible)]="renameFileVisible" - [style]="{ width: '45rem' }" + class="action-dialog" >
- +
+ +
- +
@@ -179,26 +182,33 @@ } @for (file of files().data; track $index) {
-
+
@if (file.kind !== 'folder') { -
+ } @else { - -
{{ file.kind === 'file' ? file.extra.downloads + ' Downloads' : '' }}
+
+ {{ file.kind === 'file' ? file.extra.downloads + ' ' + ('common.labels.downloads' | translate) : '' }} +
- {{ file.size ? formatFileSize(file.size) : '' }} + {{ file.size | fileSize }}
{{ file.dateModified | date: 'MMM d, y hh:mm a' }} @@ -217,7 +227,7 @@ (click)="downloadFile(file.links.download)" (keydown.enter)="downloadFile(file.links.download)" > - {{ 'project.files.menu.download' | translate }} + {{ 'common.buttons.download' | translate }} } @else { - {{ 'project.files.menu.download' | translate }} + {{ 'common.buttons.download' | translate }} } } @@ -239,7 +249,7 @@ (click)="moveFile(file, 'copy')" (keydown.enter)="moveFile(file, 'copy')" > - {{ 'project.files.menu.copy' | translate }} + {{ 'common.buttons.copy' | translate }} } @case (FileMenuItems.Move) { @@ -250,7 +260,7 @@ (click)="moveFile(file, 'move')" (keydown.enter)="moveFile(file, 'move')" > - {{ 'project.files.menu.move' | translate }} + {{ 'common.buttons.move' | translate }} } @case (FileMenuItems.Rename) { @@ -258,10 +268,14 @@ role="button" tabindex="0" class="p-menu-item-link" - (click)="renameFileVisible = true; selectedFileIndex = $index; renamedEntry.set(file.name)" + (click)=" + renameFileVisible = true; + selectedFileIndex = $index; + renameForm.get('name')!.setValue(file.name) + " (keydown.enter)="renameFileVisible = true; selectedFileIndex = $index" > - {{ 'project.files.menu.rename' | translate }} + {{ 'common.buttons.rename' | translate }} } @case (FileMenuItems.Delete) { @@ -272,7 +286,7 @@ (click)="deleteEntry(file.links.delete)" (keydown.enter)="deleteEntry(file.links.delete)" > - {{ 'project.files.menu.delete' | translate }} + {{ 'common.buttons.delete' | translate }} } } diff --git a/src/app/features/project/files/project-files.component.scss b/src/app/features/project/files/project-files.component.scss index e4e7d3372..775ed8e42 100644 --- a/src/app/features/project/files/project-files.component.scss +++ b/src/app/features/project/files/project-files.component.scss @@ -4,114 +4,116 @@ :host { @include mix.flex-column; flex: 1; -} -.files-table { - display: flex; - flex-direction: column; - border: 1px solid var.$grey-2; - border-radius: 8px; - overflow-x: auto; - min-width: 100%; - - &-row { - color: var.$dark-blue-1; - display: grid; - align-items: center; - grid-template-columns: - minmax(mix.rem(200px), 32rem) minmax(mix.rem(150px), 0.7fr) minmax(mix.rem(100px), 100px) - minmax(mix.rem(150px), 1fr) minmax(mix.rem(50px), 50px); - grid-template-rows: mix.rem(44px); - border-bottom: 1px solid var.$grey-2; - padding: 0 mix.rem(12px); - min-width: max-content; - - > div { - width: 100%; - height: 100%; - display: flex; + .files-table { + display: flex; + flex-direction: column; + border: 1px solid var.$grey-2; + border-radius: 8px; + overflow-x: auto; + min-width: 100%; + + &-row { + color: var.$dark-blue-1; + display: grid; align-items: center; + grid-template-columns: minmax(mix.rem(200px), 32rem) minmax(mix.rem(150px), 0.7fr) minmax( + mix.rem(100px), + 100px + ) minmax(mix.rem(150px), 1fr) minmax(mix.rem(50px), 50px); + grid-template-rows: mix.rem(44px); + border-bottom: 1px solid var.$grey-2; + padding: 0 mix.rem(12px); + min-width: max-content; + + .table-cell { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + + > .table-cell:first-child { + min-width: 0; + max-width: 95%; + } } - > div:first-child { - min-width: 0; - max-width: 95%; + &-row:last-child { + border-bottom: none; } + } - .flex { - min-width: 0; - } - a.flex { - min-width: 0; + .filename-link { + cursor: pointer; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; + + &:hover { + text-decoration: underline; } } - &-row:last-child { - border-bottom: none; - } -} + .icon-link { + cursor: pointer; -.filename-link { - cursor: pointer; - - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - max-width: 100%; + &:hover { + text-decoration: underline; + } + } - &:hover { - text-decoration: underline; + .parent-folder-link { + cursor: pointer; + display: flex; + gap: 5px; } -} -.icon-link { - cursor: pointer; - &:hover { - text-decoration: underline; + .sorting-container { + display: flex; + align-items: center; + border: 1px solid var.$grey-2; + border-radius: mix.rem(8px); + padding: mix.rem(12px); + height: mix.rem(44px); } -} -.parent-folder-link { - cursor: pointer; - display: flex; - gap: 5px; -} + .outline-button { + font-weight: 600; + display: flex; + justify-content: center; + gap: mix.rem(8px); + border: 1px solid var.$grey-2; + padding: mix.rem(12px); + border-radius: mix.rem(8px); + height: mix.rem(44px); + min-height: mix.rem(44px); + + &.blue { + color: var.$pr-blue-1; + } -.sorting-container { - display: flex; - align-items: center; - border: 1px solid var.$grey-2; - border-radius: mix.rem(8px); - padding: mix.rem(12px); - height: mix.rem(44px); -} + &.green { + color: var.$green-1; + } + } -.outline-button { - font-weight: 600; - display: flex; - justify-content: center; - gap: mix.rem(8px); - border: 1px solid var.$grey-2; - padding: mix.rem(12px); - border-radius: mix.rem(8px); - height: mix.rem(44px); - min-height: mix.rem(44px); - - &.blue { - color: var.$pr-blue-1; + .filename { + overflow-wrap: anywhere; } - &.green { - color: var.$green-1; + .spinner-container { + width: mix.rem(38px); } -} -.filename { - overflow-wrap: anywhere; -} + .upload-dialog { + width: mix.rem(48px); + } -.spinner-container { - width: mix.rem(38px); + .action-dialog { + width: mix.rem(72px); + } } diff --git a/src/app/features/project/files/project-files.component.ts b/src/app/features/project/files/project-files.component.ts index ad16b436c..0a746d2f3 100644 --- a/src/app/features/project/files/project-files.component.ts +++ b/src/app/features/project/files/project-files.component.ts @@ -15,28 +15,39 @@ import { debounceTime, filter, finalize, forkJoin, skip } from 'rxjs'; import { DatePipe } from '@angular/common'; import { HttpEventType } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, ElementRef, HostBinding, inject, signal, ViewChild } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule } from '@angular/forms'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + HostBinding, + inject, + signal, + ViewChild, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MoveFileDialogComponent } from '@osf/features/project/files/components'; -import { FileMenuItems, FilesSorting, OsfFile } from '@osf/features/project/files/models'; -import { ProjectFilesService } from '@osf/features/project/files/services/project-files.service'; +import { FileMenuItems, OsfFile } from '@osf/features/project/files/models'; +import { ProjectFilesService } from '@osf/features/project/files/services'; import { CreateFolder, DeleteEntry, GetFiles, GetRootFolderFiles, + ProjectFilesSelectors, RenameEntry, SetCurrentFolder, SetMoveFileCurrentFolder, SetSearch, SetSort, -} from '@osf/features/project/files/store/project-files.actions'; -import { ProjectFilesSelectors } from '@osf/features/project/files/store/project-files.selectors'; +} from '@osf/features/project/files/store'; import { LoadingSpinnerComponent, SearchInputComponent, SubHeaderComponent } from '@shared/components'; -import { formatFileSize } from '@shared/utils/format-file-size.helper'; +import { FileSizePipe } from '@shared/pipes'; + +import { FILE_MENU_ITEMS, FILE_SORT_OPTIONS } from './constants'; @Component({ selector: 'osf-project-files', @@ -52,9 +63,11 @@ import { formatFileSize } from '@shared/utils/format-file-size.helper'; Dialog, InputText, FormsModule, + ReactiveFormsModule, Menu, TranslatePipe, RouterLink, + FileSizePipe, ], templateUrl: './project-files.component.html', styleUrl: './project-files.component.scss', @@ -69,6 +82,7 @@ export class ProjectFilesComponent { readonly projectFilesService = inject(ProjectFilesService); readonly router = inject(Router); readonly activeRoute = inject(ActivatedRoute); + destroyRef = inject(DestroyRef); protected readonly files = select(ProjectFilesSelectors.getFiles); protected readonly currentFolder = select(ProjectFilesSelectors.getCurrentFolder); @@ -76,10 +90,19 @@ export class ProjectFilesComponent { protected readonly projectId = signal(''); protected readonly progress = signal(0); protected readonly fileName = signal(''); - protected readonly newFolderName = signal(''); protected readonly isFilesUpdating = signal(false); - protected readonly renamedEntry = signal(''); protected readonly searchControl = new FormControl(''); + protected readonly sortControl = new FormControl(FILE_SORT_OPTIONS[0].value); + protected readonly folderForm = new FormGroup<{ + name: FormControl; + }>({ + name: new FormControl('', { nonNullable: true }), + }); + protected readonly renameForm = new FormGroup<{ + name: FormControl; + }>({ + name: new FormControl('', { nonNullable: true }), + }); dialogRef: DynamicDialogRef | null = null; readonly #dialogService = inject(DialogService); @@ -88,45 +111,17 @@ export class ProjectFilesComponent { isFolderCreating = false; selectedFileIndex = -1; - // dialogs createFolderVisible = false; renameFileVisible = false; - items = [ - { label: FileMenuItems.Download }, - { label: FileMenuItems.Copy }, - { label: FileMenuItems.Move }, - { label: FileMenuItems.Delete }, - { label: FileMenuItems.Rename }, - ]; - - sortOptions = [ - { - value: FilesSorting.NameAZ, - label: 'Name: A-Z', - }, - { - value: FilesSorting.NameZA, - label: 'Name: Z-A', - }, - { - value: FilesSorting.LastModifiedOldest, - label: 'Last modified: oldest to newest', - }, - { - value: FilesSorting.LastModifiedNewest, - label: 'Last modified: newest to oldest', - }, - ]; - - #defaultSortValue = this.sortOptions[0].value; - protected readonly sortValue = signal(this.#defaultSortValue); + items = FILE_MENU_ITEMS; + sortOptions = FILE_SORT_OPTIONS; + readonly isBatching = signal(false); protected readonly FileMenuItems = FileMenuItems; constructor() { - // get root folder files this.activeRoute.parent?.params.subscribe((params) => { if (params['id']) { this.projectId.set(params['id']); @@ -134,25 +129,26 @@ export class ProjectFilesComponent { } }); - // put search value in store and update resources, filters this.searchControl.valueChanges .pipe( - skip(1), //skip default value from the store + skip(1), + takeUntilDestroyed(this.destroyRef), debounceTime(500), - filter(() => !this.isBatching()) // only run if not in batch mode + filter(() => !this.isBatching()) ) .subscribe((searchText) => { this.store.dispatch(new SetSearch(searchText ?? '')); this.updateFilesList(); }); - toObservable(this.sortValue) + this.sortControl.valueChanges .pipe( - skip(1), //skip default value from the store - filter(() => !this.isBatching()) // only run if not in batch mode + skip(1), + takeUntilDestroyed(this.destroyRef), + filter(() => !this.isBatching()) ) .subscribe((sort) => { - this.store.dispatch(new SetSort(sort)); + this.store.dispatch(new SetSort(sort ?? '')); this.updateFilesList(); }); } @@ -167,6 +163,7 @@ export class ProjectFilesComponent { this.projectFilesService .uploadFile(file, this.projectId(), this.currentFolder()) .pipe( + takeUntilDestroyed(this.destroyRef), finalize(() => { this.fileIsUploading = false; this.fileName.set(''); @@ -196,13 +193,15 @@ export class ProjectFilesComponent { forkJoin([ this.store.dispatch(new SetCurrentFolder(file)), this.store.dispatch(new SetSearch('')), - this.store.dispatch(new SetSort(this.#defaultSortValue)), - ]).subscribe(() => { - this.searchControl.setValue(''); - this.sortValue.set(this.#defaultSortValue); - this.isBatching.set(false); - this.updateFilesList(); - }); + this.store.dispatch(new SetSort(FILE_SORT_OPTIONS[0].value)), + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.searchControl.setValue(''); + this.sortControl.setValue(FILE_SORT_OPTIONS[0].value); + this.isBatching.set(false); + this.updateFilesList(); + }); } openParentFolder() { @@ -217,51 +216,57 @@ export class ProjectFilesComponent { forkJoin([ this.store.dispatch(new SetCurrentFolder(folder)), this.store.dispatch(new SetSearch('')), - this.store.dispatch(new SetSort(this.#defaultSortValue)), - ]).subscribe(() => { - this.searchControl.setValue(''); - this.sortValue.set(this.#defaultSortValue); - this.isBatching.set(false); - this.updateFilesList(); - }); + this.store.dispatch(new SetSort(FILE_SORT_OPTIONS[0].value)), + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.searchControl.setValue(''); + this.sortControl.setValue(FILE_SORT_OPTIONS[0].value); + this.isBatching.set(false); + this.updateFilesList(); + }); }); } createFolder(): void { this.isFolderCreating = true; - if (this.newFolderName()) { + const folderName = this.folderForm.getRawValue().name; + if (folderName.trim()) { this.store - .dispatch( - new CreateFolder(this.projectId(), this.newFolderName(), this.currentFolder()?.relationships?.parentFolderId) - ) + .dispatch(new CreateFolder(this.projectId(), folderName, this.currentFolder()?.relationships?.parentFolderId)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.createFolderVisible = false; this.isFolderCreating = false; - this.newFolderName.set(''); + this.folderForm.reset(); }); } } deleteEntry(link: string): void { this.isFilesUpdating.set(true); - this.store.dispatch(new DeleteEntry(this.projectId(), link)).subscribe(() => { - this.isFilesUpdating.set(false); - }); + this.store + .dispatch(new DeleteEntry(this.projectId(), link)) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.isFilesUpdating.set(false); + }); } renameEntry(): void { this.renameFileVisible = false; this.isFilesUpdating.set(true); const link = this.files().data[this.selectedFileIndex].links.upload; - if (this.renamedEntry()) { + const newName = this.renameForm.getRawValue().name; + if (newName.trim()) { this.store - .dispatch(new RenameEntry(this.projectId(), link, this.renamedEntry())) + .dispatch(new RenameEntry(this.projectId(), link, newName)) .pipe( + takeUntilDestroyed(this.destroyRef), finalize(() => { this.isFilesUpdating.set(false); - this.selectedFileIndex = -1; - this.renamedEntry.set(''); + this.renameForm.reset(); }) ) .subscribe(); @@ -301,33 +306,37 @@ export class ProjectFilesComponent { document.body.appendChild(iframe); } - protected readonly formatFileSize = formatFileSize; - moveFile(file: OsfFile, action: string): void { - this.store.dispatch(new SetMoveFileCurrentFolder(this.currentFolder())).subscribe(() => { - const header = action === 'move' ? 'Move file' : 'Copy file'; - this.dialogRef = this.#dialogService.open(MoveFileDialogComponent, { - width: '552px', - focusOnShow: false, - header: header, - closeOnEscape: true, - modal: true, - closable: true, - data: { - file: file, - projectId: this.projectId(), - action: action, - }, + this.store + .dispatch(new SetMoveFileCurrentFolder(this.currentFolder())) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + const header = action === 'move' ? 'Move file' : 'Copy file'; + this.dialogRef = this.#dialogService.open(MoveFileDialogComponent, { + width: '552px', + focusOnShow: false, + header: header, + closeOnEscape: true, + modal: true, + closable: true, + data: { + file: file, + projectId: this.projectId(), + action: action, + }, + }); }); - }); } updateFilesList(): void { const currentFolder = this.currentFolder(); if (currentFolder?.relationships.filesLink) { - this.store.dispatch(new GetFiles(currentFolder?.relationships.filesLink)).subscribe(() => { - this.isFilesUpdating.set(false); - }); + this.store + .dispatch(new GetFiles(currentFolder?.relationships.filesLink)) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.isFilesUpdating.set(false); + }); } else { this.store.dispatch(new GetRootFolderFiles(this.projectId())); } diff --git a/src/app/features/project/files/services/index.ts b/src/app/features/project/files/services/index.ts new file mode 100644 index 000000000..de9a40b64 --- /dev/null +++ b/src/app/features/project/files/services/index.ts @@ -0,0 +1 @@ +export * from './project-files.service'; diff --git a/src/app/features/project/files/services/project-files.service.ts b/src/app/features/project/files/services/project-files.service.ts index 75c6e5048..afd9b9976 100644 --- a/src/app/features/project/files/services/project-files.service.ts +++ b/src/app/features/project/files/services/project-files.service.ts @@ -24,11 +24,12 @@ import { OsfFileProjectContributor, PatchFileMetadata, } from '@osf/features/project/files/models'; -import { ProjectFilesSelectors } from '@osf/features/project/files/store/project-files.selectors'; +import { ProjectFilesSelectors } from '@osf/features/project/files/store'; -import { environment } from '../../../../../environments/environment'; import { MapFile, MapFileCustomMetadata, MapFiles } from '../mappers'; +import { environment } from 'src/environments/environment'; + @Injectable({ providedIn: 'root', }) @@ -88,8 +89,6 @@ export class ProjectFilesService { name: folderName, }; - console.log(); - const link = `${environment.fileApiUrl}/resources/${projectId}/providers/${this.provider()}/${parentFolderId ? parentFolderId + '/' : ''}`; return this.#jsonApiService diff --git a/src/app/features/project/files/store/index.ts b/src/app/features/project/files/store/index.ts new file mode 100644 index 000000000..b4f8c0ff3 --- /dev/null +++ b/src/app/features/project/files/store/index.ts @@ -0,0 +1,3 @@ +export * from './project-files.actions'; +export * from './project-files.selectors'; +export * from './project-files.state'; diff --git a/src/app/features/project/files/store/project-files.actions.ts b/src/app/features/project/files/store/project-files.actions.ts index 3e9602f87..233d0300f 100644 --- a/src/app/features/project/files/store/project-files.actions.ts +++ b/src/app/features/project/files/store/project-files.actions.ts @@ -1,5 +1,4 @@ -import { OsfFile } from '@osf/features/project/files/models'; -import { PatchFileMetadata } from '@osf/features/project/files/models/requests/patch-file-metadata.mode'; +import { OsfFile, PatchFileMetadata } from '@osf/features/project/files/models'; export class GetRootFolderFiles { static readonly type = '[Project Files] Get Root Folder Files'; diff --git a/src/app/features/project/files/store/project-files.model.ts b/src/app/features/project/files/store/project-files.model.ts index dcc205522..278a94d9d 100644 --- a/src/app/features/project/files/store/project-files.model.ts +++ b/src/app/features/project/files/store/project-files.model.ts @@ -1,8 +1,10 @@ -import { OsfFile } from '@osf/features/project/files/models'; -import { FileProvider } from '@osf/features/project/files/models/data/file-provider.const'; -import { OsfFileCustomMetadata } from '@osf/features/project/files/models/osf-models/file-custom-metafata.model'; -import { OsfFileProjectContributor } from '@osf/features/project/files/models/osf-models/file-project-contributor.model'; -import { OsfProjectMetadata } from '@osf/features/project/files/models/osf-models/project-custom-metadata.model'; +import { + FileProvider, + OsfFile, + OsfFileCustomMetadata, + OsfFileProjectContributor, + OsfProjectMetadata, +} from '@osf/features/project/files/models'; import { AsyncStateModel } from '@shared/models/store'; export interface ProjectFilesStateModel { diff --git a/src/app/features/project/files/store/project-files.selectors.ts b/src/app/features/project/files/store/project-files.selectors.ts index d5fad2aaf..d6cd99509 100644 --- a/src/app/features/project/files/store/project-files.selectors.ts +++ b/src/app/features/project/files/store/project-files.selectors.ts @@ -1,7 +1,7 @@ import { Selector } from '@ngxs/store'; import { OsfFile } from '@osf/features/project/files/models'; -import { OsfFileCustomMetadata } from '@osf/features/project/files/models/osf-models/file-custom-metafata.model'; +import { OsfFileCustomMetadata } from '@osf/features/project/files/models/osf-models/file-custom-metadata.model'; import { OsfFileProjectContributor } from '@osf/features/project/files/models/osf-models/file-project-contributor.model'; import { OsfProjectMetadata } from '@osf/features/project/files/models/osf-models/project-custom-metadata.model'; import { AsyncStateModel } from '@shared/models'; diff --git a/src/app/shared/pipes/file-size.pipe.ts b/src/app/shared/pipes/file-size.pipe.ts new file mode 100644 index 000000000..e90a89dd3 --- /dev/null +++ b/src/app/shared/pipes/file-size.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'fileSize', +}) +export class FileSizePipe implements PipeTransform { + transform(bytes: number): string { + if (!bytes) { + return ''; + } else if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 ** 2) { + return `${(bytes / 1024).toFixed(1)} kB`; + } else if (bytes < 1024 ** 3) { + return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + } else { + return `${(bytes / 1024 ** 3).toFixed(1)} GB`; + } + } +} diff --git a/src/app/shared/pipes/index.ts b/src/app/shared/pipes/index.ts index cfd626b56..83261cfc4 100644 --- a/src/app/shared/pipes/index.ts +++ b/src/app/shared/pipes/index.ts @@ -1,2 +1,3 @@ +export { FileSizePipe } from './file-size.pipe'; export { MonthYearPipe } from './month-year.pipe'; export { WrapFnPipe } from './wrap-fn.pipe'; diff --git a/src/app/shared/utils/format-file-size.helper.ts b/src/app/shared/utils/format-file-size.helper.ts deleted file mode 100644 index 3a65fcdd5..000000000 --- a/src/app/shared/utils/format-file-size.helper.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function formatFileSize(bytes: number): string { - if (bytes < 1024) { - return `${bytes} B`; - } else if (bytes < 1024 ** 2) { - return `${(bytes / 1024).toFixed(1)} kB`; - } else if (bytes < 1024 ** 3) { - return `${(bytes / 1024 ** 2).toFixed(1)} MB`; - } else { - return `${(bytes / 1024 ** 3).toFixed(1)} GB`; - } -} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 3dd736b3c..aafcde7c7 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -6,10 +6,17 @@ "add": "Add", "cancel": "Cancel", "save": "Save", - "close": "Close" + "close": "Close", + "download": "Download", + "copy": "Copy", + "move": "Move", + "rename": "Rename" }, "search": { "noResultsFound": "No results found." + }, + "labels": { + "downloads": "Downloads" } }, "navigation": { @@ -551,7 +558,7 @@ "lastUpdated": "Last Updated:", "description": "Description:", "openResources": "Open Resources" - } + } }, "files": { "title": "Files", @@ -576,29 +583,15 @@ "createFolder": { "title": "Create folder", "folderName": "New folder name", - "folderNamePlaceholder": "Please enter a folder name", - "buttons": { - "cancel": "Cancel", - "save": "Save" - } + "folderNamePlaceholder": "Please enter a folder name" }, "renameFile": { "title": "Rename file", - "renameLabel": "Please rename the file", - "buttons": { - "cancel": "Cancel", - "save": "Save" - } - } + "renameLabel": "Please rename the file" + }, + "moveFile": "Cannot move to the same folder" }, "emptyState": "This folder is empty", - "menu": { - "download": "Download", - "copy": "Copy", - "move": "Move", - "rename": "Rename", - "delete": "Delete" - }, "detail": { "backToList": "Back to list of files", "fileMetadata": { @@ -627,13 +620,6 @@ "dateModified": "Date modified", "contributors": "Contributors" } - }, - "editDialog": { - "title": "Edit File Metadata", - "buttons": { - "cancel": "Cancel", - "save": "Save" - } } } } diff --git a/src/assets/styles/_base.scss b/src/assets/styles/_base.scss index c8835472d..ca84fddc4 100644 --- a/src/assets/styles/_base.scss +++ b/src/assets/styles/_base.scss @@ -49,12 +49,6 @@ list-style: none; } - .inside-list { - li { - list-style: inside; - } - } - a, a:visited { text-decoration: none;