From 4249bf0e0be86677a78c294faabb6e25c498b120 Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Mon, 21 Jul 2025 23:29:13 +0300 Subject: [PATCH 1/3] feat(registration): work on file control for steps --- .../stepper/file-step/file-step.component.ts | 3 +- .../features/project/files/models/index.ts | 1 - .../project-files/project-files.component.ts | 3 +- .../custom-step/custom-step.component.html | 18 +- .../custom-step/custom-step.component.ts | 29 +++ .../files-control.component.html | 67 ++++++ .../files-control.component.scss | 0 .../files-control.component.spec.ts | 22 ++ .../files-control/files-control.component.ts | 192 ++++++++++++++++++ .../registries/mappers/page-schema.mapper.ts | 1 + .../features/registries/registries.routes.ts | 2 + src/app/features/registries/services/index.ts | 1 + .../services/registration-files.service.ts | 12 ++ .../registries/store/default.state.ts | 12 ++ .../store/handlers/files.handlers.ts | 69 +++++++ .../registries/store/registries.actions.ts | 40 ++++ .../registries/store/registries.model.ts | 5 + .../registries/store/registries.selectors.ts | 18 +- .../registries/store/registries.state.ts | 35 +++- .../registry-files.component.ts | 3 +- .../files-tree/files-tree.component.ts | 5 +- .../registration/registration.mapper.ts | 1 + .../{ => files}/file-menu-action.model.ts | 2 +- .../files}/files-tree-actions.interface.ts | 0 src/app/shared/models/files/index.ts | 5 + src/app/shared/models/index.ts | 5 +- .../shared/models/projects/projects.models.ts | 1 + .../registration-json-api.model.ts | 9 + 28 files changed, 543 insertions(+), 18 deletions(-) create mode 100644 src/app/features/registries/components/files-control/files-control.component.html create mode 100644 src/app/features/registries/components/files-control/files-control.component.scss create mode 100644 src/app/features/registries/components/files-control/files-control.component.spec.ts create mode 100644 src/app/features/registries/components/files-control/files-control.component.ts create mode 100644 src/app/features/registries/services/registration-files.service.ts create mode 100644 src/app/features/registries/store/handlers/files.handlers.ts rename src/app/shared/models/{ => files}/file-menu-action.model.ts (72%) rename src/app/{features/project/files/models => shared/models/files}/files-tree-actions.interface.ts (100%) create mode 100644 src/app/shared/models/files/index.ts diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts index 0dcb99ee5..a27718fd2 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts @@ -41,9 +41,8 @@ import { SetSelectedPreprintFileSource, UploadFile, } from '@osf/features/preprints/store/preprint-stepper'; -import { FilesTreeActions } from '@osf/features/project/files/models'; import { FilesTreeComponent, IconComponent } from '@shared/components'; -import { OsfFile } from '@shared/models'; +import { FilesTreeActions, OsfFile } from '@shared/models'; import { CustomConfirmationService, ToastService } from '@shared/services'; @Component({ diff --git a/src/app/features/project/files/models/index.ts b/src/app/features/project/files/models/index.ts index 2d4383151..923d1245d 100644 --- a/src/app/features/project/files/models/index.ts +++ b/src/app/features/project/files/models/index.ts @@ -23,4 +23,3 @@ export * from './data/embed-content.const'; export * from './data/file-provider.const'; export * from './data/project-files-state-defaults.const'; export * from './files-metadata-fields'; -export * from './files-tree-actions.interface'; diff --git a/src/app/features/project/files/pages/project-files/project-files.component.ts b/src/app/features/project/files/pages/project-files/project-files.component.ts index cad8b652f..769bae13c 100644 --- a/src/app/features/project/files/pages/project-files/project-files.component.ts +++ b/src/app/features/project/files/pages/project-files/project-files.component.ts @@ -29,7 +29,6 @@ import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { CreateFolderDialogComponent } from '@osf/features/project/files/components'; -import { FilesTreeActions } from '@osf/features/project/files/models'; import { CreateFolder, DeleteEntry, @@ -54,7 +53,7 @@ import { SearchInputComponent, SubHeaderComponent, } from '@shared/components'; -import { ConfiguredStorageAddon, OsfFile } from '@shared/models'; +import { ConfiguredStorageAddon, FilesTreeActions, OsfFile } from '@shared/models'; import { FilesService } from '@shared/services'; @Component({ diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index 690363cea..947799b61 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -153,8 +153,22 @@

Upload File

Uploaded files will automatically be archived in this registration. They will also be added to a related project that will be created for this registration.

- -

File input is not implemented yet.

+
+ @for (file of attachedFiles[question.responseKey!] || []; track file) { + + } +
+
+ +
} } diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 51551eba8..ddc88b1a2 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -5,6 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Checkbox } from 'primeng/checkbox'; +import { Chip } from 'primeng/chip'; import { Inplace } from 'primeng/inplace'; import { InputText } from 'primeng/inputtext'; import { Message } from 'primeng/message'; @@ -19,11 +20,13 @@ import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; +import { OsfFile } from '@osf/shared/models'; import { CustomValidators, findChangedFields } from '@osf/shared/utils'; import { FieldType } from '../../enums'; import { PageSchema } from '../../models'; import { RegistriesSelectors, UpdateDraft, UpdateStepValidation } from '../../store'; +import { FilesControlComponent } from '../files-control/files-control.component'; @Component({ selector: 'osf-custom-step', @@ -42,6 +45,8 @@ import { RegistriesSelectors, UpdateDraft, UpdateStepValidation } from '../../st Button, ReactiveFormsModule, Message, + FilesControlComponent, + Chip, ], templateUrl: './custom-step.component.html', styleUrl: './custom-step.component.scss', @@ -71,6 +76,8 @@ export class CustomStepComponent implements OnDestroy { stepForm!: FormGroup; + attachedFiles: Record[]> = {}; + constructor() { this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => { this.updateStepState(); @@ -83,6 +90,9 @@ export class CustomStepComponent implements OnDestroy { this.initStepForm(page); } }); + setTimeout(() => { + console.log('CustomStepComponent initialized with step:', this.attachedFiles); + }, 5000); } private initStepForm(page: PageSchema): void { @@ -144,6 +154,25 @@ export class CustomStepComponent implements OnDestroy { } } + onAttachFile(file: OsfFile, questionKey: string): void { + this.attachedFiles[questionKey] = this.attachedFiles[questionKey] || []; + if (!this.attachedFiles[questionKey].some((f) => f.id === file.id)) { + this.attachedFiles[questionKey].push(file); + this.actions.updateDraft(this.route.snapshot.params['id'], { + registration_responses: { attachedFiles: this.attachedFiles }, + }); + } + } + + removeFromAttachedFiles(file: Partial, questionKey: string): void { + if (this.attachedFiles[questionKey]) { + this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.id !== file.id); + this.actions.updateDraft(this.route.snapshot.params['id'], { + registration_responses: { attachedFiles: this.attachedFiles }, + }); + } + } + goBack(): void { const previousStep = this.step() - 1; if (previousStep > 0) { diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html new file mode 100644 index 000000000..085e983f0 --- /dev/null +++ b/src/app/features/registries/components/files-control/files-control.component.html @@ -0,0 +1,67 @@ +@if (!dataLoaded()) { + +} @else { +
+
+ +
+
+ + + + + + + +
+
+ +
+ +
+ {{ fileName() }} +
+ +
+

{{ progress() }} %

+
+
+
+ + + +
+} diff --git a/src/app/features/registries/components/files-control/files-control.component.scss b/src/app/features/registries/components/files-control/files-control.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts new file mode 100644 index 000000000..004f5e814 --- /dev/null +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FilesControlComponent } from './files-control.component'; + +describe('FilesControlComponent', () => { + let component: FilesControlComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilesControlComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(FilesControlComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts new file mode 100644 index 000000000..887fb8d67 --- /dev/null +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -0,0 +1,192 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { TreeDragDropService } from 'primeng/api'; +import { Button } from 'primeng/button'; +import { Dialog } from 'primeng/dialog'; +import { DialogService } from 'primeng/dynamicdialog'; + +import { EMPTY, filter, finalize, Observable, take } from 'rxjs'; + +import { HttpEventType } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { CreateFolderDialogComponent } from '@osf/features/project/files/components'; +import { approveFile } from '@osf/features/project/files/utils'; +import { FilesTreeComponent, LoadingSpinnerComponent } from '@osf/shared/components'; +import { FilesTreeActions, OsfFile } from '@osf/shared/models'; +import { FilesService } from '@osf/shared/services'; + +import { + CreateFolder, + GetFiles, + GetRootFolders, + RegistriesSelectors, + SetCurrentFolder, + SetFilesIsLoading, + SetMoveFileCurrentFolder, +} from '../../store'; + +@Component({ + selector: 'osf-files-control', + imports: [ + FilesTreeComponent, + Button, + LoadingSpinnerComponent, + Dialog, + FormsModule, + ReactiveFormsModule, + TranslatePipe, + ], + templateUrl: './files-control.component.html', + styleUrl: './files-control.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService, TreeDragDropService], +}) +export class FilesControlComponent { + attachedFiles = input.required[]>(); + attachFile = output(); + + private readonly filesService = inject(FilesService); + private readonly dialogService = inject(DialogService); + private readonly translateService = inject(TranslateService); + private readonly destroyRef = inject(DestroyRef); + + protected readonly files = select(RegistriesSelectors.getFiles); + protected readonly isFilesLoading = select(RegistriesSelectors.isFilesLoading); + protected readonly currentFolder = select(RegistriesSelectors.getCurrentFolder); + protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + + protected readonly progress = signal(0); + protected readonly fileName = signal(''); + protected readonly dataLoaded = signal(false); + + fileIsUploading = signal(false); + isFolderOpening = signal(false); + + private readonly actions = createDispatchMap({ + createFolder: CreateFolder, + getFiles: GetFiles, + setFilesIsLoading: SetFilesIsLoading, + setCurrentFolder: SetCurrentFolder, + getRootFolders: GetRootFolders, + setMoveFileCurrentFolder: SetMoveFileCurrentFolder, + }); + + protected readonly filesTreeActions: FilesTreeActions = { + setCurrentFolder: (folder) => this.actions.setCurrentFolder(folder), + setFilesIsLoading: (isLoading) => this.actions.setFilesIsLoading(isLoading), + getFiles: (filesLink) => this.actions.getFiles(filesLink), + setMoveFileCurrentFolder: (folder) => this.actions.setMoveFileCurrentFolder(folder), + }; + + constructor() { + effect(() => { + const filesLink = this.draftRegistration()?.branchedFrom?.filesLink; + if (filesLink) { + this.actions.getRootFolders(filesLink).subscribe(() => { + this.dataLoaded.set(true); + }); + } + }); + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + this.uploadFile(file); + } + + createFolder(): void { + const currentFolder = this.currentFolder(); + console.log('Current folder:', currentFolder); + const newFolderLink = currentFolder?.links.newFolder; + + if (!newFolderLink) return; + + this.dialogService + .open(CreateFolderDialogComponent, { + width: '448px', + focusOnShow: false, + header: this.translateService.instant('project.files.dialogs.createFolder.title'), + closeOnEscape: true, + modal: true, + closable: true, + }) + .onClose.pipe(filter((folderName: string) => !!folderName)) + .subscribe((folderName) => { + this.actions + .createFolder(newFolderLink, folderName) + .pipe( + take(1), + finalize(() => { + this.updateFilesList().subscribe(() => this.fileIsUploading.set(false)); + }) + ) + .subscribe(); + }); + } + + updateFilesList(): Observable { + const currentFolder = this.currentFolder(); + if (currentFolder?.relationships.filesLink) { + this.filesTreeActions.setFilesIsLoading?.(true); + return this.actions.getFiles(currentFolder?.relationships.filesLink).pipe(take(1)); + } + + return EMPTY; + } + + uploadFile(file: File): void { + const currentFolder = this.currentFolder(); + const uploadLink = currentFolder?.links.upload; + if (!uploadLink) return; + + this.fileName.set(file.name); + this.fileIsUploading.set(true); + + this.filesService + .uploadFile(file, uploadLink) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => { + this.fileIsUploading.set(false); + this.fileName.set(''); + 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; + const branchedFromId = this.draftRegistration()?.branchedFrom?.id; + if (fileId && branchedFromId) { + approveFile(fileId, branchedFromId); + } + } + } + }); + } + + selectFile(file: OsfFile): void { + console.log('File selected:', file); + this.attachFile.emit(file); + } + + folderIsOpening(value: boolean): void { + this.isFolderOpening.set(value); + // if (value) { + // this.fileName.set(''); + // this.progress.set(0); + // } + } +} diff --git a/src/app/features/registries/mappers/page-schema.mapper.ts b/src/app/features/registries/mappers/page-schema.mapper.ts index 6bfd884fa..cd399808c 100644 --- a/src/app/features/registries/mappers/page-schema.mapper.ts +++ b/src/app/features/registries/mappers/page-schema.mapper.ts @@ -126,6 +126,7 @@ export class PageSchemaMapper { case BlockType.FileInput: if (currentQuestion) { currentQuestion.fieldType = FieldType.File; + currentQuestion.responseKey = item.attributes.registration_response_key || undefined; } break; default: diff --git a/src/app/features/registries/registries.routes.ts b/src/app/features/registries/registries.routes.ts index 8dd5e3833..480f8811c 100644 --- a/src/app/features/registries/registries.routes.ts +++ b/src/app/features/registries/registries.routes.ts @@ -7,6 +7,7 @@ import { RegistriesState } from '@osf/features/registries/store'; import { ContributorsState, SubjectsState } from '@osf/shared/stores'; import { LicensesHandlers, ProjectsHandlers, ProvidersHandlers } from './store/handlers'; +import { FilesHandlers } from './store/handlers/files.handlers'; import { LicensesService } from './services'; export const registriesRoutes: Routes = [ @@ -18,6 +19,7 @@ export const registriesRoutes: Routes = [ ProvidersHandlers, ProjectsHandlers, LicensesHandlers, + FilesHandlers, LicensesService, ], children: [ diff --git a/src/app/features/registries/services/index.ts b/src/app/features/registries/services/index.ts index b6471bbb9..c4eb84e78 100644 --- a/src/app/features/registries/services/index.ts +++ b/src/app/features/registries/services/index.ts @@ -1,4 +1,5 @@ export * from './licenses.service'; export * from './projects.service'; export * from './providers.service'; +export * from './registration-files.service'; export * from './registries.service'; diff --git a/src/app/features/registries/services/registration-files.service.ts b/src/app/features/registries/services/registration-files.service.ts new file mode 100644 index 000000000..564022082 --- /dev/null +++ b/src/app/features/registries/services/registration-files.service.ts @@ -0,0 +1,12 @@ +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@core/services'; +import { FilesService } from '@osf/shared/services'; + +@Injectable({ + providedIn: 'root', +}) +export class RegistrationFilesService { + private filesService = inject(FilesService); + private jsonApiService = inject(JsonApiService); +} diff --git a/src/app/features/registries/store/default.state.ts b/src/app/features/registries/store/default.state.ts index c5e68f17a..f70d1cd21 100644 --- a/src/app/features/registries/store/default.state.ts +++ b/src/app/features/registries/store/default.state.ts @@ -51,4 +51,16 @@ export const DefaultState: RegistriesStateModel = { error: null, totalCount: 0, }, + files: { + data: [], + isLoading: false, + error: null, + }, + currentFolder: null, + moveFileCurrentFolder: null, + rootFolders: { + data: null, + isLoading: false, + error: null, + }, }; diff --git a/src/app/features/registries/store/handlers/files.handlers.ts b/src/app/features/registries/store/handlers/files.handlers.ts new file mode 100644 index 000000000..bc237bc4a --- /dev/null +++ b/src/app/features/registries/store/handlers/files.handlers.ts @@ -0,0 +1,69 @@ +import { StateContext } from '@ngxs/store'; + +import { catchError, finalize, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@osf/core/handlers'; +import { FilesService } from '@osf/shared/services'; + +import { CreateFolder, GetFiles, GetRootFolders } from '../registries.actions'; +import { RegistriesStateModel } from '../registries.model'; + +@Injectable() +export class FilesHandlers { + filesService = inject(FilesService); + + getRootFolders(ctx: StateContext, action: GetRootFolders) { + const state = ctx.getState(); + ctx.patchState({ rootFolders: { ...state.rootFolders, isLoading: true, error: null }, currentFolder: null }); + + return this.filesService.getFolders(action.folderLink).pipe( + tap({ + next: (folders) => + ctx.patchState({ + rootFolders: { + data: folders, + isLoading: false, + error: null, + }, + currentFolder: folders.length > 0 ? folders[0] : null, + }), + }), + catchError((error) => handleSectionError(ctx, 'rootFolders', error)) + ); + } + + getProjectFiles(ctx: StateContext, { filesLink }: GetFiles) { + const state = ctx.getState(); + console.log('Fetching project files from:', filesLink); + ctx.patchState({ + files: { + ...state.files, + isLoading: true, + }, + }); + + return this.filesService.getFilesWithoutFiltering(filesLink).pipe( + tap((response) => { + ctx.patchState({ + files: { + data: response, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'files', error)) + ); + } + + createFolder(ctx: StateContext, action: CreateFolder) { + const state = ctx.getState(); + ctx.patchState({ files: { ...state.files, isLoading: true, error: null } }); + + return this.filesService + .createFolder(action.newFolderLink, action.folderName) + .pipe(finalize(() => ctx.patchState({ files: { ...state.files, isLoading: false, error: null } }))); + } +} diff --git a/src/app/features/registries/store/registries.actions.ts b/src/app/features/registries/store/registries.actions.ts index 10bd0a03f..bdbacfe13 100644 --- a/src/app/features/registries/store/registries.actions.ts +++ b/src/app/features/registries/store/registries.actions.ts @@ -2,6 +2,7 @@ import { DraftRegistrationAttributesJsonApi, DraftRegistrationRelationshipsJsonApi, LicenseOptions, + OsfFile, } from '@osf/shared/models'; export class GetRegistries { @@ -103,3 +104,42 @@ export class FetchProjectChildren { export class ClearState { static readonly type = '[Registries] Clear State'; } + +export class GetFiles { + static readonly type = '[Registries] Get Files'; + + constructor(public filesLink: string) {} +} + +export class SetFilesIsLoading { + static readonly type = '[Registries] Set Files Loading'; + + constructor(public isLoading: boolean) {} +} + +export class GetRootFolders { + static readonly type = '[Registries] Get Folders'; + + constructor(public folderLink: string) {} +} + +export class CreateFolder { + static readonly type = '[Registries] Create folder'; + + constructor( + public newFolderLink: string, + public folderName: string + ) {} +} + +export class SetCurrentFolder { + static readonly type = '[Registries] Set Current Folder'; + + constructor(public folder: OsfFile | null) {} +} + +export class SetMoveFileCurrentFolder { + static readonly type = '[Registries] Set Move File Current Folder'; + + constructor(public folder: OsfFile | null) {} +} diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index ec41ab1b2..ae1f71555 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -3,6 +3,7 @@ import { AsyncStateWithTotalCount, DraftRegistrationModel, License, + OsfFile, RegistrationCard, RegistrationModel, Resource, @@ -21,4 +22,8 @@ export interface RegistriesStateModel { stepsValidation: Record; draftRegistrations: AsyncStateWithTotalCount; submittedRegistrations: AsyncStateWithTotalCount; + files: AsyncStateModel; + currentFolder: OsfFile | null; + moveFileCurrentFolder: OsfFile | null; + rootFolders: AsyncStateModel; } diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 52bf4eacf..67de5d52b 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { DraftRegistrationModel, License, RegistrationCard, Resource } from '@shared/models'; +import { DraftRegistrationModel, License, OsfFile, RegistrationCard, Resource } from '@shared/models'; import { PageSchema, Project, ProviderSchema } from '../models'; @@ -127,8 +127,24 @@ export class RegistriesSelectors { static getSubmittedRegistrationsTotalCount(state: RegistriesStateModel): number { return state.submittedRegistrations.totalCount; } + + @Selector([RegistriesState]) + static getFiles(state: RegistriesStateModel): OsfFile[] { + return state.files.data; + } + + @Selector([RegistriesState]) + static isFilesLoading(state: RegistriesStateModel): boolean { + return state.files.isLoading; + } + @Selector([RegistriesState]) static getRegistrationComponents(state: RegistriesStateModel) { return state.draftRegistration.data?.components || []; } + + @Selector([RegistriesState]) + static getCurrentFolder(state: RegistriesStateModel): OsfFile | null { + return state.currentFolder; + } } diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index 4fc10dc29..f4f37244c 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -6,11 +6,12 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/core/handlers'; import { ResourceTab } from '@osf/shared/enums'; -import { SearchService } from '@osf/shared/services'; +import { FilesService, SearchService } from '@osf/shared/services'; import { getResourceTypes } from '@osf/shared/utils'; import { RegistriesService } from '../services'; +import { FilesHandlers } from './handlers/files.handlers'; import { LicensesHandlers } from './handlers/licenses.handlers'; import { ProjectsHandlers } from './handlers/projects.handlers'; import { ProvidersHandlers } from './handlers/providers.handlers'; @@ -18,6 +19,7 @@ import { DefaultState } from './default.state'; import { ClearState, CreateDraft, + CreateFolder, DeleteDraft, FetchDraft, FetchDraftRegistrations, @@ -25,11 +27,15 @@ import { FetchProjectChildren, FetchSchemaBlocks, FetchSubmittedRegistrations, + GetFiles, GetProjects, GetProviderSchemas, GetRegistries, + GetRootFolders, RegisterDraft, SaveLicense, + SetCurrentFolder, + SetMoveFileCurrentFolder, UpdateDraft, UpdateStepValidation, } from './registries.actions'; @@ -43,10 +49,12 @@ import { RegistriesStateModel } from './registries.model'; export class RegistriesState { searchService = inject(SearchService); registriesService = inject(RegistriesService); + fileService = inject(FilesService); providersHandler = inject(ProvidersHandlers); projectsHandler = inject(ProjectsHandlers); licensesHandler = inject(LicensesHandlers); + filesHandlers = inject(FilesHandlers); @Action(GetRegistries) getRegistries(ctx: StateContext) { @@ -318,4 +326,29 @@ export class RegistriesState { clearState(ctx: StateContext) { ctx.setState({ ...DefaultState }); } + + @Action(GetFiles) + getFiles(ctx: StateContext, { filesLink }: GetFiles) { + return this.filesHandlers.getProjectFiles(ctx, { filesLink }); + } + + @Action(GetRootFolders) + getRootFolders(ctx: StateContext, action: GetRootFolders) { + return this.filesHandlers.getRootFolders(ctx, action); + } + + @Action(CreateFolder) + createFolder(ctx: StateContext, action: CreateFolder) { + return this.filesHandlers.createFolder(ctx, action); + } + + @Action(SetMoveFileCurrentFolder) + setMoveFileCurrentFolder(ctx: StateContext, action: SetMoveFileCurrentFolder) { + ctx.patchState({ moveFileCurrentFolder: action.folder }); + } + + @Action(SetCurrentFolder) + setSelectedFolder(ctx: StateContext, action: SetCurrentFolder) { + ctx.patchState({ currentFolder: action.folder }); + } } diff --git a/src/app/features/registry/pages/registry-files/registry-files.component.ts b/src/app/features/registry/pages/registry-files/registry-files.component.ts index 6b6b708a1..26001b37b 100644 --- a/src/app/features/registry/pages/registry-files/registry-files.component.ts +++ b/src/app/features/registry/pages/registry-files/registry-files.component.ts @@ -13,7 +13,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { FilesTreeActions } from '@osf/features/project/files/models'; import { FilesTreeComponent, FormSelectComponent, @@ -22,7 +21,7 @@ import { SubHeaderComponent, } from '@shared/components'; import { ALL_SORT_OPTIONS } from '@shared/constants'; -import { OsfFile } from '@shared/models'; +import { FilesTreeActions, OsfFile } from '@shared/models'; import { FilesService } from '@shared/services'; import { diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index 4f4debb9d..84ca45dfb 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -26,10 +26,11 @@ import { import { ActivatedRoute, Router } from '@angular/router'; import { MoveFileDialogComponent, RenameFileDialogComponent } from '@osf/features/project/files/components'; -import { embedDynamicJs, embedStaticHtml, FilesTreeActions } from '@osf/features/project/files/models'; +import { embedDynamicJs, embedStaticHtml } from '@osf/features/project/files/models'; import { FileMenuType } from '@osf/shared/enums'; +import { FileMenuAction, FilesTreeActions } from '@osf/shared/models/files'; import { FileMenuComponent, LoadingSpinnerComponent } from '@shared/components'; -import { FileMenuAction, OsfFile } from '@shared/models'; +import { OsfFile } from '@shared/models'; import { FileSizePipe } from '@shared/pipes'; import { CustomConfirmationService, FilesService, ToastService } from '@shared/services'; diff --git a/src/app/shared/mappers/registration/registration.mapper.ts b/src/app/shared/mappers/registration/registration.mapper.ts index 236f3c719..7c0c7deef 100644 --- a/src/app/shared/mappers/registration/registration.mapper.ts +++ b/src/app/shared/mappers/registration/registration.mapper.ts @@ -28,6 +28,7 @@ export class RegistrationMapper { branchedFrom: { id: response.embeds?.branched_from?.data.id, title: response.embeds?.branched_from?.data.attributes.title, + filesLink: response.embeds?.branched_from?.data.relationships?.files?.links?.related?.href, }, providerId: response.relationships.provider?.data?.id || '', hasProject: !!response.attributes.has_project, diff --git a/src/app/shared/models/file-menu-action.model.ts b/src/app/shared/models/files/file-menu-action.model.ts similarity index 72% rename from src/app/shared/models/file-menu-action.model.ts rename to src/app/shared/models/files/file-menu-action.model.ts index 1148134ff..d5566932d 100644 --- a/src/app/shared/models/file-menu-action.model.ts +++ b/src/app/shared/models/files/file-menu-action.model.ts @@ -1,4 +1,4 @@ -import { FileMenuType } from '../enums'; +import { FileMenuType } from '@osf/shared/enums'; export interface FileMenuAction { value: FileMenuType; diff --git a/src/app/features/project/files/models/files-tree-actions.interface.ts b/src/app/shared/models/files/files-tree-actions.interface.ts similarity index 100% rename from src/app/features/project/files/models/files-tree-actions.interface.ts rename to src/app/shared/models/files/files-tree-actions.interface.ts diff --git a/src/app/shared/models/files/index.ts b/src/app/shared/models/files/index.ts new file mode 100644 index 000000000..5365dfd23 --- /dev/null +++ b/src/app/shared/models/files/index.ts @@ -0,0 +1,5 @@ +export * from './file.model'; +export * from './file-menu-action.model'; +export * from './files-tree-actions.interface'; +export * from './get-configured-storage-addons.model'; +export * from './get-files-response.model'; diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index 3b9d4951c..e7574b81b 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -6,10 +6,7 @@ export * from './charts'; export * from './confirmation-options.model'; export * from './contributors'; export * from './create-component-form.model'; -export * from './file-menu-action.model'; -export * from './files/file.model'; -export * from './files/get-configured-storage-addons.model'; -export * from './files/get-files-response.model'; +export * from './files'; export * from './filter-labels.model'; export * from './filters'; export * from './google-drive-folder.model'; diff --git a/src/app/shared/models/projects/projects.models.ts b/src/app/shared/models/projects/projects.models.ts index 4e6ecdd88..76216a1b5 100644 --- a/src/app/shared/models/projects/projects.models.ts +++ b/src/app/shared/models/projects/projects.models.ts @@ -11,4 +11,5 @@ export interface Project { licenseOptions: LicenseOptions | null; description: string; tags: string[]; + filesLink?: string; } diff --git a/src/app/shared/models/registration/registration-json-api.model.ts b/src/app/shared/models/registration/registration-json-api.model.ts index ec4db52c3..9272d3c84 100644 --- a/src/app/shared/models/registration/registration-json-api.model.ts +++ b/src/app/shared/models/registration/registration-json-api.model.ts @@ -134,6 +134,15 @@ export interface DraftRegistrationEmbedsJsonApi extends RegistrationEmbedsJsonAp attributes: { title: string; }; + relationships?: { + files?: { + links: { + related: { + href: string; + }; + }; + }; + }; }; }; } From 443af907b7d8e0b03ac329759f92a5b3015bbe06 Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Tue, 22 Jul 2025 13:13:13 +0300 Subject: [PATCH 2/3] feat(registration): attach files to registration --- .../custom-step/custom-step.component.html | 7 +++-- .../custom-step/custom-step.component.ts | 25 ++++++++++++++--- .../files-control/files-control.component.ts | 17 +++++------- .../components/review/review.component.html | 27 +++++++++++++++---- .../registries/mappers/files.mapper.ts | 17 ++++++++++++ .../registries/mappers/page-schema.mapper.ts | 1 + .../store/handlers/files.handlers.ts | 1 - .../files-tree/files-tree.component.html | 11 ++++++-- .../files-tree/files-tree.component.scss | 3 ++- .../registration/registration.mapper.ts | 16 +++++++---- .../files/file-payload-json-api.model.ts | 11 ++++++++ src/app/shared/models/files/index.ts | 1 + .../registration-json-api.model.ts | 5 ++++ src/assets/i18n/en.json | 4 +++ 14 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 src/app/features/registries/mappers/files.mapper.ts create mode 100644 src/app/shared/models/files/file-payload-json-api.model.ts diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index 947799b61..3559c33a6 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -145,13 +145,12 @@

} } @case (FieldType.File) { -

Upload File

+

{{ 'project.files.actions.uploadFile' | translate }}

- You may attach up to 5 file(s) to this question. Files cannot total over 5GB in size. + {{ 'shared.files.limitText' | translate }}

- Uploaded files will automatically be archived in this registration. They will also be added to a - related project that will be created for this registration. + {{ 'shared.files.description' | translate }}

@for (file of attachedFiles[question.responseKey!] || []; track file) { diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index ddc88b1a2..36492ff99 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -20,10 +20,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; -import { OsfFile } from '@osf/shared/models'; +import { FilePayloadJsonApi, OsfFile } from '@osf/shared/models'; import { CustomValidators, findChangedFields } from '@osf/shared/utils'; import { FieldType } from '../../enums'; +import { FilesMapper } from '../../mappers/files.mapper'; import { PageSchema } from '../../models'; import { RegistriesSelectors, UpdateDraft, UpdateStepValidation } from '../../store'; import { FilesControlComponent } from '../files-control/files-control.component'; @@ -123,6 +124,14 @@ export class CustomStepComponent implements OnDestroy { }); break; + case FieldType.File: + control = this.fb.control(this.stepsData()[controlName] || [], { + validators: q.required ? [Validators.required] : [], + }); + this.attachedFiles[controlName] = + this.stepsData()[controlName]?.map((file: FilePayloadJsonApi) => ({ ...file, name: file.file_name })) || []; + break; + default: console.warn(`Unsupported field type: ${q.fieldType}`); return; @@ -158,8 +167,13 @@ export class CustomStepComponent implements OnDestroy { this.attachedFiles[questionKey] = this.attachedFiles[questionKey] || []; if (!this.attachedFiles[questionKey].some((f) => f.id === file.id)) { this.attachedFiles[questionKey].push(file); + this.stepForm.patchValue({ + [questionKey]: [...(this.attachedFiles[questionKey] || []), file], + }); this.actions.updateDraft(this.route.snapshot.params['id'], { - registration_responses: { attachedFiles: this.attachedFiles }, + registration_responses: { + [questionKey]: [...this.attachedFiles[questionKey].map((f) => FilesMapper.toFilePayload(f as OsfFile))], + }, }); } } @@ -167,8 +181,13 @@ export class CustomStepComponent implements OnDestroy { removeFromAttachedFiles(file: Partial, questionKey: string): void { if (this.attachedFiles[questionKey]) { this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.id !== file.id); + this.stepForm.patchValue({ + [questionKey]: this.attachedFiles[questionKey], + }); this.actions.updateDraft(this.route.snapshot.params['id'], { - registration_responses: { attachedFiles: this.attachedFiles }, + registration_responses: { + [questionKey]: [...this.attachedFiles[questionKey].map((f) => FilesMapper.toFilePayload(f as OsfFile))], + }, }); } } diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index 887fb8d67..8f2feeac0 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -7,7 +7,7 @@ import { Button } from 'primeng/button'; import { Dialog } from 'primeng/dialog'; import { DialogService } from 'primeng/dynamicdialog'; -import { EMPTY, filter, finalize, Observable, take } from 'rxjs'; +import { EMPTY, filter, finalize, Observable, shareReplay, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core'; @@ -87,9 +87,12 @@ export class FilesControlComponent { effect(() => { const filesLink = this.draftRegistration()?.branchedFrom?.filesLink; if (filesLink) { - this.actions.getRootFolders(filesLink).subscribe(() => { - this.dataLoaded.set(true); - }); + this.actions + .getRootFolders(filesLink) + .pipe(shareReplay(), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.dataLoaded.set(true); + }); } }); } @@ -104,7 +107,6 @@ export class FilesControlComponent { createFolder(): void { const currentFolder = this.currentFolder(); - console.log('Current folder:', currentFolder); const newFolderLink = currentFolder?.links.newFolder; if (!newFolderLink) return; @@ -178,15 +180,10 @@ export class FilesControlComponent { } selectFile(file: OsfFile): void { - console.log('File selected:', file); this.attachFile.emit(file); } folderIsOpening(value: boolean): void { this.isFolderOpening.set(value); - // if (value) { - // this.fileName.set(''); - // this.progress.set(0); - // } } } diff --git a/src/app/features/registries/components/review/review.component.html b/src/app/features/registries/components/review/review.component.html index e2bda9dca..bc38e322a 100644 --- a/src/app/features/registries/components/review/review.component.html +++ b/src/app/features/registries/components/review/review.component.html @@ -98,12 +98,29 @@

{{ page.title }}

{{ question.displayText }}

@if (stepsData()[question.responseKey!]) { - @if (question.fieldType === FieldType.Checkbox) { - @for (option of stepsData()[question.responseKey!]; track option) { -

{{ option }}

+ @switch (question.fieldType) { + @case (FieldType.Text) { +

{{ stepsData()[question.responseKey!] }}

+ } + @case (FieldType.Checkbox) { + @for (option of stepsData()[question.responseKey!]; track option) { +

{{ option }}

+ } + } + @case (FieldType.File) { + @if (stepsData()[question.responseKey!].length) { +
+ @for (file of stepsData()[question.responseKey!]; track file.id) { + + } +
+ } @else { +

{{ 'common.labels.noFiles' | translate }}

+ } + } + @default { +

{{ stepsData()[question.responseKey!] }}

} - } @else { -

{{ stepsData()[question.responseKey!] }}

} } @else { @if (question.fieldType === FieldType.File) { diff --git a/src/app/features/registries/mappers/files.mapper.ts b/src/app/features/registries/mappers/files.mapper.ts new file mode 100644 index 000000000..bde6bd742 --- /dev/null +++ b/src/app/features/registries/mappers/files.mapper.ts @@ -0,0 +1,17 @@ +import { FilePayloadJsonApi, OsfFile } from '@osf/shared/models'; + +export class FilesMapper { + static toFilePayload(file: OsfFile): FilePayloadJsonApi { + return { + file_id: file.id, + file_name: file.name, + file_urls: { + html: file.links.html, + download: file.links.download, + }, + file_hashes: { + sha256: file.extra?.hashes?.sha256 || '', + }, + }; + } +} diff --git a/src/app/features/registries/mappers/page-schema.mapper.ts b/src/app/features/registries/mappers/page-schema.mapper.ts index cd399808c..3a60c6add 100644 --- a/src/app/features/registries/mappers/page-schema.mapper.ts +++ b/src/app/features/registries/mappers/page-schema.mapper.ts @@ -126,6 +126,7 @@ export class PageSchemaMapper { case BlockType.FileInput: if (currentQuestion) { currentQuestion.fieldType = FieldType.File; + currentQuestion.required = item.attributes.required; currentQuestion.responseKey = item.attributes.registration_response_key || undefined; } break; diff --git a/src/app/features/registries/store/handlers/files.handlers.ts b/src/app/features/registries/store/handlers/files.handlers.ts index bc237bc4a..077d0a08c 100644 --- a/src/app/features/registries/store/handlers/files.handlers.ts +++ b/src/app/features/registries/store/handlers/files.handlers.ts @@ -36,7 +36,6 @@ export class FilesHandlers { getProjectFiles(ctx: StateContext, { filesLink }: GetFiles) { const state = ctx.getState(); - console.log('Fetching project files from:', filesLink); ctx.patchState({ files: { ...state.files, diff --git a/src/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index 880cf6fbc..1e2c8492c 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -94,8 +94,15 @@ @if (!files().length) { -
-

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

+
+ @if (!viewOnly()) { +

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

+ } @else { +
+ +

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

+
+ }
}
diff --git a/src/app/shared/components/files-tree/files-tree.component.scss b/src/app/shared/components/files-tree/files-tree.component.scss index 922feb710..10b30e8c3 100644 --- a/src/app/shared/components/files-tree/files-tree.component.scss +++ b/src/app/shared/components/files-tree/files-tree.component.scss @@ -2,7 +2,7 @@ @use "assets/styles/mixins" as mix; :host { - min-height: 200px; + min-height: 180px; } .files-table { @@ -12,6 +12,7 @@ border-radius: 8px; overflow-x: auto; min-width: 100%; + min-height: 180px; &-row { color: var.$dark-blue-1; diff --git a/src/app/shared/mappers/registration/registration.mapper.ts b/src/app/shared/mappers/registration/registration.mapper.ts index 7c0c7deef..8dc7ced0b 100644 --- a/src/app/shared/mappers/registration/registration.mapper.ts +++ b/src/app/shared/mappers/registration/registration.mapper.ts @@ -25,11 +25,17 @@ export class RegistrationMapper { }, tags: response.attributes.tags || [], stepsData: response.attributes.registration_responses || {}, - branchedFrom: { - id: response.embeds?.branched_from?.data.id, - title: response.embeds?.branched_from?.data.attributes.title, - filesLink: response.embeds?.branched_from?.data.relationships?.files?.links?.related?.href, - }, + branchedFrom: response.embeds?.branched_from?.data + ? { + id: response.embeds.branched_from.data.id, + title: response.embeds.branched_from.data.attributes.title, + filesLink: response.embeds?.branched_from?.data.relationships?.files?.links?.related?.href, + } + : { + id: response.relationships.branched_from?.data?.id || '', + title: response.attributes.title, + filesLink: response.relationships.branched_from?.links?.related.href + 'files/', + }, providerId: response.relationships.provider?.data?.id || '', hasProject: !!response.attributes.has_project, components: [], diff --git a/src/app/shared/models/files/file-payload-json-api.model.ts b/src/app/shared/models/files/file-payload-json-api.model.ts new file mode 100644 index 000000000..4d35e6ea3 --- /dev/null +++ b/src/app/shared/models/files/file-payload-json-api.model.ts @@ -0,0 +1,11 @@ +export interface FilePayloadJsonApi { + file_id: string; + file_name: string; + file_urls: { + html: string; + download: string; + }; + file_hashes: { + sha256: string; + }; +} diff --git a/src/app/shared/models/files/index.ts b/src/app/shared/models/files/index.ts index 5365dfd23..9a106b31a 100644 --- a/src/app/shared/models/files/index.ts +++ b/src/app/shared/models/files/index.ts @@ -1,5 +1,6 @@ export * from './file.model'; export * from './file-menu-action.model'; +export * from './file-payload-json-api.model'; export * from './files-tree-actions.interface'; export * from './get-configured-storage-addons.model'; export * from './get-files-response.model'; diff --git a/src/app/shared/models/registration/registration-json-api.model.ts b/src/app/shared/models/registration/registration-json-api.model.ts index 9272d3c84..d9f45be9e 100644 --- a/src/app/shared/models/registration/registration-json-api.model.ts +++ b/src/app/shared/models/registration/registration-json-api.model.ts @@ -75,6 +75,11 @@ export interface DraftRegistrationRelationshipsJsonApi { id: string; type: 'nodes'; }; + links?: { + related: { + href: string; + }; + }; }; } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 56a20cb47..50ea68b0b 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2063,6 +2063,10 @@ }, "tags": { "title": "Tags" + }, + "files": { + "limitText": "You may attach up to 5 file(s) to this question. Files cannot total over 5GB in size.", + "description": "Uploaded files will automatically be archived in this registration. They will also be added to a related project that will be created for this registration." } }, "resourceCard": { From 7c9b284c8a512e040ea883be48832749efc40eda Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Tue, 22 Jul 2025 14:27:38 +0300 Subject: [PATCH 3/3] feat(registration): remove useless code --- .../registries/components/custom-step/custom-step.component.ts | 3 --- src/app/shared/components/files-tree/files-tree.component.html | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 36492ff99..fdad01c82 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -91,9 +91,6 @@ export class CustomStepComponent implements OnDestroy { this.initStepForm(page); } }); - setTimeout(() => { - console.log('CustomStepComponent initialized with step:', this.attachedFiles); - }, 5000); } private initStepForm(page: PageSchema): void { diff --git a/src/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index 1e2c8492c..3282cfd12 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -95,7 +95,7 @@ @if (!files().length) {
- @if (!viewOnly()) { + @if (viewOnly()) {

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

} @else {