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/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 08aaef8d4..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 @@ -1,73 +1,36 @@ - +
- +
-
-
-
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 b780eab7c..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 @@ -1,17 +1,69 @@ -import { Button } from 'primeng/button'; +import { select, Store } from '@ngxs/store'; -import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; -import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; -import { SubHeaderComponent } from '@shared/components'; +import { ChangeDetectionStrategy, Component, DestroyRef, HostBinding, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { + GetFileMetadata, + GetFileProjectContributors, + GetFileProjectMetadata, + GetFileTarget, + 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], + imports: [ + SubHeaderComponent, + RouterLink, + LoadingSpinnerComponent, + TranslateModule, + FileMetadataComponent, + ProjectMetadataComponent, + ], 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); + + file = select(ProjectFilesSelectors.getOpenedFile); + safeLink: SafeResourceUrl | null = null; + + isIframeLoading = true; + + constructor() { + 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)); + } + }); + } } 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..010b5a3b3 --- /dev/null +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.html @@ -0,0 +1,80 @@ +@if (files().isLoading || isFilesUpdating()) { +
+ +
+} @else { +
+
+ cost-shield +

OSF Storage

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

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

+
+ } +
+
+} + +
+ + + @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..9e846a4cc --- /dev/null +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.scss @@ -0,0 +1,94 @@ +@use "assets/styles/variables" as var; +@use "/assets/styles/mixins" as mix; + +:host { + @include mix.flex-column; + flex: 1; + gap: mix.rem(24px); + + .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: 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; + } + } + + .filename-link { + cursor: pointer; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; + + &:hover { + text-decoration: underline; + } + } + + .parent-folder-link { + cursor: pointer; + display: flex; + gap: mix.rem(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); + } + + .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); + + &.blue { + color: var.$pr-blue-1; + } + + &.green { + color: var.$green-1; + } + } + + .filename { + overflow-wrap: anywhere; + } + + .spinner-container { + width: mix.rem(38px); + } + + .disabled-icon { + color: var.$grey-1; + } +} 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..5012f1e76 --- /dev/null +++ b/src/app/features/project/files/components/move-file-dialog/move-file-dialog.component.ts @@ -0,0 +1,127 @@ +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, take } from 'rxjs'; + +import { NgOptimizedImage } from '@angular/common'; +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'; +import { + GetFiles, + GetMoveFileFiles, + GetRootFolderFiles, + ProjectFilesSelectors, + SetCurrentFolder, + SetFilesIsLoading, + SetMoveFileCurrentFolder, +} from '@osf/features/project/files/store'; +import { LoadingSpinnerComponent } from '@shared/components'; + +@Component({ + selector: 'osf-move-file-dialog', + imports: [Button, LoadingSpinnerComponent, NgOptimizedImage, Tooltip, TranslateModule], + 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); + destroyRef = inject(DestroyRef); + + 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; + }); + + 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.dispatch.getMoveFileFiles(filesLink); + } + } + + openFolder(file: OsfFile) { + if (file.kind !== 'folder') return; + + this.dispatch.getMoveFileFiles(file.relationships.filesLink); + this.dispatch.setMoveFileCurrentFolder(file); + } + + openParentFolder() { + const currentFolder = this.currentFolder(); + + if (!currentFolder) return; + + this.isFilesUpdating.set(true); + this.projectFilesService + .getFolder(currentFolder.relationships.parentFolderLink) + .pipe( + take(1), + takeUntilDestroyed(this.destroyRef), + finalize(() => { + this.isFilesUpdating.set(false); + }) + ) + .subscribe((folder) => { + this.dispatch.setMoveFileCurrentFolder(folder); + this.dispatch.getMoveFileFiles(folder.relationships.filesLink); + }); + } + + moveFile(): void { + let path = this.currentFolder()?.path; + + if (!path) { + throw new Error('Path is not specified!.'); + } + + if (!this.currentFolder()?.relationships.parentFolderLink) { + path = '/'; + } + + 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.dispatch.setCurrentFolder(this.currentFolder()); + this.dispatch.setMoveFileCurrentFolder(undefined); + }) + ) + .subscribe((file) => { + if (file.id) { + const filesLink = this.currentFolder()?.relationships.filesLink; + if (filesLink) { + this.dispatch.getFiles(filesLink); + } else { + this.dispatch.getRootFolderFiles(this.config.data.projectId); + } + } + }); + this.dialogRef.close(); + } +} 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 new file mode 100644 index 000000000..2611fe4c8 --- /dev/null +++ b/src/app/features/project/files/mappers/file-custom-metadata.mapper.ts @@ -0,0 +1,12 @@ +import { ApiData } from '@osf/core/models'; +import { FileCustomMetadata, OsfFileCustomMetadata } from '@osf/features/project/files/models'; + +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..caf5a309f --- /dev/null +++ b/src/app/features/project/files/mappers/project-metadata.mapper.ts @@ -0,0 +1,27 @@ +import { + GetProjectCustomMetadataResponse, + GetProjectShortInfoResponse, + OsfProjectMetadata, +} from '@osf/features/project/files/models'; + +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..b16f0079e --- /dev/null +++ b/src/app/features/project/files/models/index.ts @@ -0,0 +1,24 @@ +// OSF Models +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'; +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.model'; + +// 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-metadata.model.ts b/src/app/features/project/files/models/osf-models/file-custom-metadata.model.ts new file mode 100644 index 000000000..aace1c784 --- /dev/null +++ b/src/app/features/project/files/models/osf-models/file-custom-metadata.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.model.ts b/src/app/features/project/files/models/requests/patch-file-metadata.model.ts new file mode 100644 index 000000000..b2a9869dc --- /dev/null +++ b/src/app/features/project/files/models/requests/patch-file-metadata.model.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..e68e1d3b6 100644 --- a/src/app/features/project/files/project-files.component.html +++ b/src/app/features/project/files/project-files.component.html @@ -1,122 +1,313 @@ - - + - +
+
+ + + + +
-@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.size }} + {{ file.kind === 'file' ? file.extra.downloads + ' ' + ('common.labels.downloads' | translate) : '' }}
+
- {{ file.modifiedAt | date: 'MMM d, y hh:mm a' }} + {{ file.size | fileSize }}
+ {{ file.dateModified | date: 'MMM d, y hh:mm a' }} +
+
+ + {{ 'project.files.menu.download' | translate }} + + @switch (item.label) { + @case (FileMenuItems.Download) { + @if (file.kind === 'file') { + + {{ 'common.buttons.download' | translate }} + + } @else { + + {{ 'common.buttons.download' | translate }} + + } + } + @case (FileMenuItems.Copy) { + + {{ 'common.buttons.copy' | translate }} + + } + @case (FileMenuItems.Move) { + + {{ 'common.buttons.move' | translate }} + + } + @case (FileMenuItems.Rename) { + + {{ 'common.buttons.rename' | translate }} + + } + @case (FileMenuItems.Delete) { + + {{ 'common.buttons.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..775ed8e42 100644 --- a/src/app/features/project/files/project-files.component.scss +++ b/src/app/features/project/files/project-files.component.scss @@ -4,52 +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; - padding: 0 12px; + .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%; + } + } + + &-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; + } + } + + .icon-link { + cursor: pointer; - &-row { - color: var.$dark-blue-1; - display: grid; + &:hover { + text-decoration: underline; + } + } + + .parent-folder-link { + cursor: pointer; + display: flex; + gap: 5px; + } + + .sorting-container { + display: flex; align-items: center; - grid-template-columns: 3fr 1fr 1fr 1fr 0.5fr; - grid-template-rows: 38px; - border-bottom: 1px solid var.$grey-2; + border: 1px solid var.$grey-2; + border-radius: mix.rem(8px); + padding: mix.rem(12px); + height: mix.rem(44px); } - &-row:last-child { - border-bottom: none; + .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; + } + + &.green { + color: var.$green-1; + } } -} -.sorting-container { - display: flex; - align-items: center; - border: 1px solid var.$grey-2; - border-radius: 8px; - padding: 12px; - height: 44px; -} + .filename { + overflow-wrap: anywhere; + } -.outline-button { - font-weight: 600; - display: flex; - gap: 8px; - border: 1px solid var.$grey-2; - padding: 12px; - border-radius: 8px; - height: 44px; + .spinner-container { + width: mix.rem(38px); + } - &.blue { - color: var.$pr-blue-1; + .upload-dialog { + width: mix.rem(48px); } - &.green { - color: var.$green-1; + .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 1737501e5..0a746d2f3 100644 --- a/src/app/features/project/files/project-files.component.ts +++ b/src/app/features/project/files/project-files.component.ts @@ -1,47 +1,344 @@ +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 { debounceTime, filter, finalize, forkJoin, skip } from 'rxjs'; + import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, HostBinding, inject, signal } from '@angular/core'; -import { Router } from '@angular/router'; +import { HttpEventType } from '@angular/common/http'; +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 { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; +import { MoveFileDialogComponent } from '@osf/features/project/files/components'; +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'; +import { LoadingSpinnerComponent, SearchInputComponent, SubHeaderComponent } from '@shared/components'; +import { FileSizePipe } from '@shared/pipes'; -import { FileItem, FILES } from './model'; +import { FILE_MENU_ITEMS, FILE_SORT_OPTIONS } from './constants'; @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, + ReactiveFormsModule, + Menu, + TranslatePipe, + RouterLink, + FileSizePipe, + ], 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); + destroyRef = inject(DestroyRef); + + 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 isFilesUpdating = signal(false); + 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); + + fileIsUploading = false; + isFolderCreating = false; + selectedFileIndex = -1; + + createFolderVisible = false; + renameFileVisible = false; + + items = FILE_MENU_ITEMS; + sortOptions = FILE_SORT_OPTIONS; + + readonly isBatching = signal(false); + + protected readonly FileMenuItems = FileMenuItems; + + constructor() { + this.activeRoute.parent?.params.subscribe((params) => { + if (params['id']) { + this.projectId.set(params['id']); + this.store.dispatch(new GetRootFolderFiles(params['id'])); + } + }); + + this.searchControl.valueChanges + .pipe( + skip(1), + takeUntilDestroyed(this.destroyRef), + debounceTime(500), + filter(() => !this.isBatching()) + ) + .subscribe((searchText) => { + this.store.dispatch(new SetSearch(searchText ?? '')); + this.updateFilesList(); + }); + + this.sortControl.valueChanges + .pipe( + skip(1), + takeUntilDestroyed(this.destroyRef), + filter(() => !this.isBatching()) + ) + .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( + takeUntilDestroyed(this.destroyRef), + 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(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() { + 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(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(); + }); + }); + } - return; + createFolder(): void { + this.isFolderCreating = true; + const folderName = this.folderForm.getRawValue().name; + if (folderName.trim()) { + this.store + .dispatch(new CreateFolder(this.projectId(), folderName, this.currentFolder()?.relationships?.parentFolderId)) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.createFolderVisible = false; + this.isFolderCreating = false; + this.folderForm.reset(); + }); } + } + + deleteEntry(link: string): void { + this.isFilesUpdating.set(true); + this.store + .dispatch(new DeleteEntry(this.projectId(), link)) + .pipe(takeUntilDestroyed(this.destroyRef)) + .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; + const newName = this.renameForm.getRawValue().name; + if (newName.trim()) { + this.store + .dispatch(new RenameEntry(this.projectId(), link, newName)) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => { + this.isFilesUpdating.set(false); + this.selectedFileIndex = -1; + this.renameForm.reset(); + }) + ) + .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); + } + + moveFile(file: OsfFile, action: string): void { + 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)) + .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 new file mode 100644 index 000000000..afd9b9976 --- /dev/null +++ b/src/app/features/project/files/services/project-files.service.ts @@ -0,0 +1,202 @@ +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'; + +import { MapFile, MapFileCustomMetadata, MapFiles } from '../mappers'; + +import { environment } from 'src/environments/environment'; + +@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, + }; + + 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/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 new file mode 100644 index 000000000..233d0300f --- /dev/null +++ b/src/app/features/project/files/store/project-files.actions.ts @@ -0,0 +1,116 @@ +import { OsfFile, PatchFileMetadata } from '@osf/features/project/files/models'; + +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..278a94d9d --- /dev/null +++ b/src/app/features/project/files/store/project-files.model.ts @@ -0,0 +1,22 @@ +import { + FileProvider, + OsfFile, + OsfFileCustomMetadata, + OsfFileProjectContributor, + OsfProjectMetadata, +} from '@osf/features/project/files/models'; +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..d6cd99509 --- /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-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'; + +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/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/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/assets/i18n/en.json b/src/assets/i18n/en.json index cf36b64ef..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": { @@ -552,6 +559,69 @@ "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" + }, + "renameFile": { + "title": "Rename file", + "renameLabel": "Please rename the file" + }, + "moveFile": "Cannot move to the same folder" + }, + "emptyState": "This folder is empty", + "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" + } + } + } } }, "settings": { @@ -1068,4 +1138,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/_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', };