diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.html b/src/app/features/preprints/components/stepper/file-step/file-step.component.html index e11ec2ac7..a2da41c20 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.html +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.html @@ -7,22 +7,20 @@

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

| translate: { preprintWord: provider().preprintWord | titlecase } }}

-

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

-@if (!preprintHasPrimaryFile() || versionFileMode()) { +@if (preprint() && !preprintHasPrimaryFile()) {
{{ 'preprints.preprintStepper.file.title' | translate }} [styleClass]="isFileSourceSelected() ? 'w-full cursor-not-allowed' : 'w-full'" [label]="'preprints.preprintStepper.file.selectFromProject' | translate" severity="secondary" - [disabled]="isFileSourceSelected() || versionFileMode()" - [pTooltip]="'preprints.preprintStepper.file.tooltips.projectDisabled' | translate" - tooltipPosition="top" + [disabled]="isFileSourceSelected()" (onClick)="selectFileSource(PreprintFileSource.Project)" />
} -@if (selectedFileSource() === PreprintFileSource.Computer) { +@if (selectedFileSource() === PreprintFileSource.Computer && !preprintFile() && !isPreprintFileLoading()) {
@if (!fileUploadLink()) { - } @else if ((!preprintFile() && !isPreprintFileLoading()) || versionFileMode()) { + } @else { @@ -63,39 +59,37 @@

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

@if (selectedFileSource() === PreprintFileSource.Project && !preprintFile() && !isPreprintFileLoading()) { -
-

- {{ 'preprints.preprintStepper.projectSelection.description' | translate }} -

-

- {{ 'preprints.preprintStepper.projectSelection.subDescription' | translate }} -

+

+ {{ 'preprints.preprintStepper.projectSelection.description' | translate }} +

+

+ {{ 'preprints.preprintStepper.projectSelection.subDescription' | translate }} +

- -
+
- @if (selectedProjectId()) { + @if (selectedProjectId() && currentFolder(); as folder) { {{ 'preprints.preprintStepper.file.title' | translate }} } -@if (!versionFileMode()) { -
- @if (isPreprintFileLoading()) { - - } @else if (preprintFile()) { -
-
- -

{{ preprintFile()!.name }}

-
-
- } -
-} +
+ @if (isPreprintFileLoading()) { + + } @else if (preprintFile(); as file) { +
+
+ +

{{ file.name }}

+
+ + @if (canDeletePrimaryFile()) { + + } +
+ } +
{{ 'preprints.preprintStepper.file.title' | translate }} class="w-6 md:w-9rem" styleClass="w-full" [label]="'common.buttons.next' | translate" - [disabled]="!canProceedToNext()" - [pTooltip]=" - !canProceedToNext() ? ('preprints.preprintStepper.common.validation.fillRequiredFields' | translate) : '' - " + [disabled]="!preprintFile()" + [pTooltip]="!preprintFile() ? ('preprints.preprintStepper.common.validation.fillRequiredFields' | translate) : ''" tooltipPosition="top" (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts b/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts index 0c72697f5..33580c85e 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts @@ -8,16 +8,16 @@ import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PreprintFileSource } from '@osf/features/preprints/enums'; -import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; +import { PreprintFileSource, ReviewsState } from '@osf/features/preprints/enums'; +import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { CopyFileFromProject, + DeletePreprintFile, FetchAvailableProjects, FetchPreprintFilesLinks, FetchPreprintPrimaryFile, FetchProjectFilesByLink, PreprintStepperSelectors, - ReuploadFile, SetPreprintStepperCurrentFolder, SetProjectRootFolder, SetSelectedPreprintFileSource, @@ -25,30 +25,58 @@ import { } from '@osf/features/preprints/store/preprint-stepper'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { FileModelMock } from '@testing/mocks/file.model.mock'; import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { FileStepComponent } from './file-step.component'; +interface SetupOverrides extends BaseSetupOverrides { + provider?: PreprintProviderDetails; + detectChanges?: boolean; +} + describe('FileStepComponent', () => { let component: FileStepComponent; let fixture: ComponentFixture; let store: Store; let toastServiceMock: ToastServiceMockType; + let confirmationMock: CustomConfirmationServiceMockType; const originalPointerEvent = (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockPreprint: PreprintModel = PREPRINT_MOCK; - const mockProjectFiles: FileFolderModel[] = [OSF_FILE_MOCK]; - const mockPreprintFile: FileFolderModel = OSF_FILE_MOCK; + const mockPreprint = { ...PREPRINT_MOCK, reviewsState: ReviewsState.Initial }; + const mockPreprintFile = FileModelMock.simple({ + id: 'file-123', + name: 'example.pdf', + links: { + info: '', + move: 'move', + upload: '', + delete: '/v2/files/file-123/', + download: '', + render: '', + html: '', + self: '', + }, + }); const mockCurrentFolder: FileFolderModel = OSF_FILE_MOCK; const mockAvailableProjects = [ { id: 'project-1', name: 'Test Project 1' }, @@ -63,31 +91,32 @@ describe('FileStepComponent', () => { { selector: PreprintStepperSelectors.isPreprintFilesLoading, value: false }, { selector: PreprintStepperSelectors.getAvailableProjects, value: mockAvailableProjects }, { selector: PreprintStepperSelectors.areAvailableProjectsLoading, value: false }, - { selector: PreprintStepperSelectors.getProjectFiles, value: mockProjectFiles }, + { selector: PreprintStepperSelectors.getProjectFiles, value: [OSF_FILE_MOCK] }, { selector: PreprintStepperSelectors.getFilesTotalCount, value: 1 }, { selector: PreprintStepperSelectors.areProjectFilesLoading, value: false }, { selector: PreprintStepperSelectors.getCurrentFolder, value: mockCurrentFolder }, { selector: PreprintStepperSelectors.isCurrentFolderLoading, value: false }, ]; - function setup(overrides?: { - selectorOverrides?: SignalOverride[]; - provider?: PreprintProviderDetails; - detectChanges?: boolean; - }) { - const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + function setup(overrides: SetupOverrides = {}) { toastServiceMock = ToastServiceMock.simple(); + confirmationMock = CustomConfirmationServiceMock.simple(); TestBed.configureTestingModule({ imports: [FileStepComponent, ...MockComponents(IconComponent, FilesTreeComponent)], - providers: [provideOSFCore(), MockProvider(ToastService, toastServiceMock), provideMockStore({ signals })], + providers: [ + provideOSFCore(), + MockProvider(ToastService, toastServiceMock), + MockProvider(CustomConfirmationService, confirmationMock), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), + ], }); store = TestBed.inject(Store); fixture = TestBed.createComponent(FileStepComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('provider', overrides?.provider ?? mockProvider); - if (overrides?.detectChanges ?? true) { + fixture.componentRef.setInput('provider', overrides.provider ?? mockProvider); + if (overrides.detectChanges ?? true) { fixture.detectChanges(); } } @@ -110,46 +139,97 @@ describe('FileStepComponent', () => { vi.useRealTimers(); }); - it('should create', () => { - setup(); - expect(component).toBeTruthy(); - }); - - it('should compute state values', () => { + it('should create and expose default computed state', () => { setup({ detectChanges: false }); + + expect(component).toBeTruthy(); + expect(component.showDeleteButton()).toBe(false); expect(component.preprintHasPrimaryFile()).toBe(true); + expect(component.canDeletePrimaryFile()).toBe(true); expect(component.isFileSourceSelected()).toBe(false); - expect(component.canProceedToNext()).toBe(true); expect(component.cancelSourceOptionButtonVisible()).toBe(false); + + fixture.componentRef.setInput('showDeleteButton', true); + expect(component.showDeleteButton()).toBe(true); }); - it('should dispatch fetch links and primary file fetch in ngOnInit', () => { + it('should disallow delete for non-initial and non-rejected review states', () => { setup({ - selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprintFile, value: null }], + selectorOverrides: [ + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, reviewsState: ReviewsState.Pending }, + }, + ], + detectChanges: false, + }); + + expect(component.canDeletePrimaryFile()).toBe(false); + }); + + it('should allow delete for rejected review state', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, reviewsState: ReviewsState.Rejected }, + }, + ], detectChanges: false, }); - component.ngOnInit(); + expect(component.canDeletePrimaryFile()).toBe(true); + }); + + it('should show cancel source button when source selected and file missing', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprintFile, value: null }, + { selector: PreprintStepperSelectors.getSelectedFileSource, value: PreprintFileSource.Computer }, + ], + detectChanges: false, + }); + + expect(component.cancelSourceOptionButtonVisible()).toBe(true); + }); + + it('should dispatch fetch links and skip primary file when file is already loaded', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintFilesLinks()); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintPrimaryFile)); + }); + + it('should dispatch fetch links and primary file when primary file id exists without loaded file', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprintFile, value: null }], + }); expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintFilesLinks()); expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); }); - it('should not dispatch primary file fetch in ngOnInit without primary file id', () => { + it('should dispatch fetch links only when primary file id is missing', () => { setup({ selectorOverrides: [ { selector: PreprintStepperSelectors.getPreprint, value: { ...mockPreprint, primaryFileId: null } }, ], - detectChanges: false, }); - component.ngOnInit(); - expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintFilesLinks()); expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintPrimaryFile)); }); - it('should dispatch available projects from debounced projectNameControl value', () => { + it('should skip init fetches when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintFilesLinks)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintPrimaryFile)); + }); + + it('should dispatch available projects from debounced project search', () => { vi.useFakeTimers(); setup(); (store.dispatch as Mock).mockClear(); @@ -172,7 +252,7 @@ describe('FileStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects('project-1')); }); - it('should handle selectFileSource for project and computer source', () => { + it('should select file source and fetch projects only for project source', () => { setup({ detectChanges: false }); component.selectFileSource(PreprintFileSource.Project); @@ -186,25 +266,19 @@ describe('FileStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects(null)); }); - it('should emit backClicked', () => { + it('should emit back and delete outputs', () => { setup({ detectChanges: false }); - const emitSpy = vi.spyOn(component.backClicked, 'emit'); + const backSpy = vi.spyOn(component.backClicked, 'emit'); + const deleteSpy = vi.spyOn(component.deleteClicked, 'emit'); component.backButtonClicked(); - - expect(emitSpy).toHaveBeenCalled(); - }); - - it('should emit deleteClicked when deletePreprint is called', () => { - setup({ detectChanges: false }); - const emitSpy = vi.spyOn(component.deleteClicked, 'emit'); - component.deletePreprint(); - expect(emitSpy).toHaveBeenCalled(); + expect(backSpy).toHaveBeenCalled(); + expect(deleteSpy).toHaveBeenCalled(); }); - it('should handle nextButtonClicked for allowed and blocked states', () => { + it('should emit next and show success when file and primary file id exist', () => { setup({ detectChanges: false }); const emitSpy = vi.spyOn(component.nextClicked, 'emit'); @@ -213,57 +287,45 @@ describe('FileStepComponent', () => { expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( 'preprints.preprintStepper.common.successMessages.preprintSaved' ); - expect(emitSpy).toHaveBeenCalledTimes(1); - - component.versionFileMode.set(true); - toastServiceMock.showSuccess.mockClear(); - - component.nextButtonClicked(); - - expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledTimes(1); + expect(emitSpy).toHaveBeenCalled(); + }); - vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, primaryFileId: null } as PreprintModel); - component.versionFileMode.set(false); + it('should block next when file requirements are missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprintFile, value: null }], + detectChanges: false, + }); + const emitSpy = vi.spyOn(component.nextClicked, 'emit'); component.nextButtonClicked(); expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledTimes(1); + expect(emitSpy).not.toHaveBeenCalled(); }); - it('should skip file upload dispatches when no file is selected', () => { + it('should upload selected file', () => { setup({ detectChanges: false }); - + const file = new File(['content'], 'upload.pdf'); const input = document.createElement('input'); - Object.defineProperty(input, 'files', { value: [] }); + Object.defineProperty(input, 'files', { value: [file] }); + component.onFileSelected({ target: input } as unknown as Event); - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UploadFile)); - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReuploadFile)); + expect(store.dispatch).toHaveBeenCalledWith(new UploadFile(file)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintPrimaryFile)); }); - it('should handle upload and reupload flows in onFileSelected', () => { + it('should skip upload when no file is selected', () => { setup({ detectChanges: false }); - const file = new File(['file-body'], 'test.txt'); const input = document.createElement('input'); - Object.defineProperty(input, 'files', { value: [file] }); - - component.onFileSelected({ target: input } as unknown as Event); - expect(store.dispatch).toHaveBeenCalledWith(new UploadFile(file)); - expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); - - (store.dispatch as Mock).mockClear(); - component.versionFileMode.set(true); + Object.defineProperty(input, 'files', { value: [] }); component.onFileSelected({ target: input } as unknown as Event); - expect(store.dispatch).toHaveBeenCalledWith(new ReuploadFile(file)); - expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); - expect(component.versionFileMode()).toBe(false); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UploadFile)); }); - it('should handle selectProject with and without current folder files link', () => { + it('should select project and load files when folder link exists', () => { setup({ detectChanges: false }); component.selectProject({ @@ -271,12 +333,16 @@ describe('FileStepComponent', () => { originalEvent: new PointerEvent('click'), } as SelectChangeEvent); + expect(component.selectedProjectId()).toBe('project-1'); expect(store.dispatch).toHaveBeenCalledWith(new SetProjectRootFolder('project-1')); expect(store.dispatch).toHaveBeenCalledWith(new FetchProjectFilesByLink(mockCurrentFolder.links.filesLink, 1)); - expect(component.selectedProjectId()).toBe('project-1'); + }); - vi.spyOn(component, 'currentFolder').mockReturnValue(null); - (store.dispatch as Mock).mockClear(); + it('should select project without loading files when folder link is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getCurrentFolder, value: null }], + detectChanges: false, + }); component.selectProject({ value: 'project-1', @@ -287,7 +353,7 @@ describe('FileStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectFilesByLink)); }); - it('should return early in selectProject when original event is not pointer event', () => { + it('should ignore project selection when original event is not a pointer event', () => { setup({ detectChanges: false }); component.selectProject({ @@ -298,39 +364,97 @@ describe('FileStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetProjectRootFolder)); }); - it('should dispatch copy file from project and preprint file fetch', () => { + it('should copy project file', () => { setup({ detectChanges: false }); - const projectFile = OSF_FILE_MOCK as unknown as FileModel; + const projectFile = FileModelMock.simple({ id: 'project-file-1' }); component.selectProjectFile(projectFile); expect(store.dispatch).toHaveBeenCalledWith(new CopyFileFromProject(projectFile)); - expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintPrimaryFile)); }); - it('should handle cancelButtonClicked for file present and file missing states', () => { + it('should confirm delete preprint file and clear project selection on confirm', () => { setup({ detectChanges: false }); + component.selectedProjectId.set('project-1'); + component.projectNameControl.setValue('project-1', { emitEvent: false }); - component.cancelButtonClicked(); - expect(store.dispatch).not.toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); + component.deletePreprintFile(); + + expect(confirmationMock.confirmDelete).toHaveBeenCalledWith({ + headerKey: 'files.dialogs.deleteFile.title', + messageParams: { name: mockPreprintFile.name }, + messageKey: 'files.dialogs.deleteFile.message', + onConfirm: expect.any(Function), + }); + + const { onConfirm } = confirmationMock.confirmDelete.mock.calls[0][0]; + onConfirm(); - vi.spyOn(component, 'preprintFile').mockReturnValue(null); + expect(store.dispatch).toHaveBeenCalledWith(new DeletePreprintFile(mockPreprintFile.links.delete)); + expect(component.selectedProjectId()).toBeNull(); + expect(component.projectNameControl.value).toBeNull(); + expect(toastServiceMock.showSuccess).toHaveBeenCalledWith('files.dialogs.deleteFile.success'); + }); + + it('should skip delete preprint file when file is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprintFile, value: null }], + detectChanges: false, + }); + + component.deletePreprintFile(); + + expect(confirmationMock.confirmDelete).not.toHaveBeenCalled(); + }); + + it('should skip delete preprint file when review state does not allow delete', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, reviewsState: ReviewsState.Pending }, + }, + ], + detectChanges: false, + }); + + component.deletePreprintFile(); + + expect(confirmationMock.confirmDelete).not.toHaveBeenCalled(); + }); + + it('should reset file source and project selection on cancel when file is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprintFile, value: null }], + detectChanges: false, + }); + component.selectedProjectId.set('project-1'); + component.projectNameControl.setValue('project-1', { emitEvent: false }); (store.dispatch as Mock).mockClear(); component.cancelButtonClicked(); + + expect(component.selectedProjectId()).toBeNull(); + expect(component.projectNameControl.value).toBeNull(); expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); }); - it('should handle setCurrentFolder for unchanged and changed folders', () => { + it('should keep file source on cancel when file is present', () => { + setup({ detectChanges: false }); + + component.cancelButtonClicked(); + + expect(store.dispatch).not.toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); + }); + + it('should set current folder only when folder id changes', () => { setup({ detectChanges: false }); component.setCurrentFolder(mockCurrentFolder); expect(store.dispatch).not.toHaveBeenCalledWith(new SetPreprintStepperCurrentFolder(mockCurrentFolder)); - expect(store.dispatch).not.toHaveBeenCalledWith(new FetchProjectFilesByLink(mockCurrentFolder.links.filesLink, 1)); - (store.dispatch as Mock).mockClear(); const nextFolder = { ...mockCurrentFolder, id: 'folder-2' } as FileFolderModel; - component.setCurrentFolder(nextFolder); expect(store.dispatch).toHaveBeenCalledWith(new SetPreprintStepperCurrentFolder(nextFolder)); @@ -344,19 +468,4 @@ describe('FileStepComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new FetchProjectFilesByLink('/v2/nodes/node-456/files/', 3)); }); - - it('should default showDeleteButton to false', () => { - setup(); - - expect(component.showDeleteButton()).toBe(false); - }); - - it('should update showDeleteButton when input changes', () => { - setup(); - - fixture.componentRef.setInput('showDeleteButton', true); - fixture.detectChanges(); - - expect(component.showDeleteButton()).toBe(true); - }); }); 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 854f901b5..48ac4a585 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 @@ -16,25 +16,27 @@ import { Component, computed, DestroyRef, + effect, inject, input, OnInit, output, signal, + untracked, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { PreprintFileSource } from '@osf/features/preprints/enums'; +import { PreprintFileSource, ReviewsState } from '@osf/features/preprints/enums'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { CopyFileFromProject, + DeletePreprintFile, FetchAvailableProjects, FetchPreprintFilesLinks, FetchPreprintPrimaryFile, FetchProjectFilesByLink, PreprintStepperSelectors, - ReuploadFile, SetPreprintStepperCurrentFolder, SetProjectRootFolder, SetSelectedPreprintFileSource, @@ -47,6 +49,7 @@ import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; @Component({ @@ -70,14 +73,15 @@ import { ToastService } from '@osf/shared/services/toast.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileStepComponent implements OnInit { - private toastService = inject(ToastService); - private destroyRef = inject(DestroyRef); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly customConfirmationService = inject(CustomConfirmationService); private actions = createDispatchMap({ setSelectedFileSource: SetSelectedPreprintFileSource, getPreprintFilesLinks: FetchPreprintFilesLinks, + deletePreprintFile: DeletePreprintFile, uploadFile: UploadFile, - reuploadFile: ReuploadFile, fetchPreprintFile: FetchPreprintPrimaryFile, getAvailableProjects: FetchAvailableProjects, setProjectRootFolder: SetProjectRootFolder, @@ -88,50 +92,71 @@ export class FileStepComponent implements OnInit { readonly PreprintFileSource = PreprintFileSource; - provider = input.required(); - showDeleteButton = input(false); - preprint = select(PreprintStepperSelectors.getPreprint); - selectedFileSource = select(PreprintStepperSelectors.getSelectedFileSource); - fileUploadLink = select(PreprintStepperSelectors.getUploadLink); + readonly provider = input.required(); + readonly showDeleteButton = input(false); - preprintFile = select(PreprintStepperSelectors.getPreprintFile); - isPreprintFileLoading = select(PreprintStepperSelectors.isPreprintFilesLoading); + readonly nextClicked = output(); + readonly backClicked = output(); + readonly deleteClicked = output(); - availableProjects = select(PreprintStepperSelectors.getAvailableProjects); - areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading); + readonly preprint = select(PreprintStepperSelectors.getPreprint); + readonly selectedFileSource = select(PreprintStepperSelectors.getSelectedFileSource); + readonly fileUploadLink = select(PreprintStepperSelectors.getUploadLink); - projectFiles = select(PreprintStepperSelectors.getProjectFiles); - filesTotalCount = select(PreprintStepperSelectors.getFilesTotalCount); - areProjectFilesLoading = select(PreprintStepperSelectors.areProjectFilesLoading); + readonly preprintFile = select(PreprintStepperSelectors.getPreprintFile); + readonly isPreprintFileLoading = select(PreprintStepperSelectors.isPreprintFilesLoading); - currentFolder = select(PreprintStepperSelectors.getCurrentFolder); - isCurrentFolderLoading = select(PreprintStepperSelectors.isCurrentFolderLoading); - selectedProjectId = signal(null); + readonly availableProjects = select(PreprintStepperSelectors.getAvailableProjects); + readonly areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading); - versionFileMode = signal(false); + readonly projectFiles = select(PreprintStepperSelectors.getProjectFiles); + readonly filesTotalCount = select(PreprintStepperSelectors.getFilesTotalCount); + readonly areProjectFilesLoading = select(PreprintStepperSelectors.areProjectFilesLoading); - preprintHasPrimaryFile = computed(() => !!this.preprint()?.primaryFileId); + readonly currentFolder = select(PreprintStepperSelectors.getCurrentFolder); + readonly isCurrentFolderLoading = select(PreprintStepperSelectors.isCurrentFolderLoading); + readonly selectedProjectId = signal(null); - cancelSourceOptionButtonVisible = computed( + readonly preprintId = computed(() => this.preprint()?.id ?? null); + readonly primaryFileId = computed(() => this.preprint()?.primaryFileId ?? null); + readonly preprintHasPrimaryFile = computed(() => !!this.primaryFileId()); + + readonly canDeletePrimaryFile = computed(() => { + const reviewsState = this.preprint()?.reviewsState; + return reviewsState === ReviewsState.Initial || reviewsState === ReviewsState.Rejected; + }); + + readonly cancelSourceOptionButtonVisible = computed( () => !this.preprintFile() && this.selectedFileSource() !== PreprintFileSource.None && !this.isPreprintFileLoading() ); - projectNameControl = new FormControl(null); + readonly projectNameControl = new FormControl(null); - nextClicked = output(); - backClicked = output(); - deleteClicked = output(); + readonly isFileSourceSelected = computed(() => this.selectedFileSource() !== PreprintFileSource.None); - isFileSourceSelected = computed(() => this.selectedFileSource() !== PreprintFileSource.None); - canProceedToNext = computed(() => !!this.preprintFile() && !this.versionFileMode()); + constructor() { + effect(() => { + if (!this.preprintId()) { + return; + } - ngOnInit() { - this.actions.getPreprintFilesLinks(); + this.actions.getPreprintFilesLinks(); + }); + + effect(() => { + if (!this.primaryFileId()) { + return; + } + + if (untracked(() => this.preprintFile())) { + return; + } - if (this.preprintHasPrimaryFile() && !this.preprintFile()) { this.actions.fetchPreprintFile(); - } + }); + } + ngOnInit() { this.projectNameControl.valueChanges .pipe(debounceTime(500), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((projectNameOrId) => { @@ -156,7 +181,7 @@ export class FileStepComponent implements OnInit { } nextButtonClicked() { - if (!this.canProceedToNext() || !this.preprint()?.primaryFileId) { + if (!this.preprintFile() || !this.primaryFileId()) { return; } @@ -173,14 +198,7 @@ export class FileStepComponent implements OnInit { const file = input.files?.[0]; if (!file) return; - const isVersionFileMode = this.versionFileMode(); - - if (isVersionFileMode) { - this.versionFileMode.set(false); - } - - const uploadAction = isVersionFileMode ? this.actions.reuploadFile(file) : this.actions.uploadFile(file); - uploadAction.subscribe(() => this.actions.fetchPreprintFile()); + this.actions.uploadFile(file).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } selectProject(event: SelectChangeEvent) { @@ -200,13 +218,38 @@ export class FileStepComponent implements OnInit { } else { return EMPTY; } - }) + }), + takeUntilDestroyed(this.destroyRef) ) .subscribe(); } selectProjectFile(file: FileModel) { - this.actions.copyFileFromProject(file).subscribe(() => this.actions.fetchPreprintFile()); + this.actions.copyFileFromProject(file).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); + } + + deletePreprintFile() { + const file = this.preprintFile(); + + if (!file || !this.canDeletePrimaryFile()) { + return; + } + + this.customConfirmationService.confirmDelete({ + headerKey: 'files.dialogs.deleteFile.title', + messageParams: { name: file.name }, + messageKey: 'files.dialogs.deleteFile.message', + onConfirm: () => { + this.actions + .deletePreprintFile(file.links.delete) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.selectedProjectId.set(null); + this.projectNameControl.setValue(null, { emitEvent: false }); + this.toastService.showSuccess('files.dialogs.deleteFile.success'); + }); + }, + }); } cancelButtonClicked() { @@ -214,6 +257,8 @@ export class FileStepComponent implements OnInit { return; } + this.selectedProjectId.set(null); + this.projectNameControl.setValue(null, { emitEvent: false }); this.actions.setSelectedFileSource(PreprintFileSource.None); } diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts index 4466c5155..01baff0b7 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts @@ -53,10 +53,10 @@ export class UpdatePrimaryFileRelationship { constructor(public fileId: string) {} } -export class ReuploadFile { - static readonly type = '[Preprint Stepper] Reupload File'; +export class DeletePreprintFile { + static readonly type = '[Preprint Stepper] Delete Preprint File'; - constructor(public file: File) {} + constructor(public deleteLink: string) {} } export class CopyFileFromProject { @@ -132,7 +132,7 @@ export class CreateNewProject { } export class SubmitPreprint { - static readonly type = '[Preprint Stepper] Preprint Stepper'; + static readonly type = '[Preprint Stepper] Submit Preprint'; } export class CreateNewVersion { diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts index 7606d7947..f30440c62 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts @@ -14,6 +14,7 @@ import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { FilesService } from '@osf/shared/services/files.service'; import { LicensesService } from '@osf/shared/services/licenses.service'; +import { PreprintFileSource } from '../../enums'; import { PreprintFilesService } from '../../services/preprint-files.service'; import { PreprintLicensesService } from '../../services/preprint-licenses.service'; import { PreprintsService } from '../../services/preprints.service'; @@ -25,6 +26,7 @@ import { CreateNewProject, CreateNewVersion, CreatePreprint, + DeletePreprintFile, DisconnectProject, FetchAvailableProjects, FetchLicenses, @@ -34,7 +36,6 @@ import { FetchPreprintProject, FetchProjectFilesByLink, ResetPreprintStepperState, - ReuploadFile, SaveLicense, SetInstitutionsChanged, SetPreprintStepperCurrentFolder, @@ -110,7 +111,8 @@ export class PreprintStepperState { return this.preprintFilesService.getPreprintFilesLinks(state.preprint.data.id).pipe( tap((preprintStorage) => { ctx.setState(patch({ preprintFilesLinks: patch({ isLoading: false, data: preprintStorage }) })); - }) + }), + catchError((error) => handleSectionError(ctx, 'preprintFilesLinks', error)) ); } @@ -130,7 +132,8 @@ export class PreprintStepperState { const createdFileId = file.id.split('/')[1]; return ctx.dispatch(new UpdatePrimaryFileRelationship(createdFileId)); - }) + }), + catchError((error) => handleSectionError(ctx, 'preprintFile', error)) ); } @@ -157,20 +160,31 @@ export class PreprintStepperState { ); } - @Action(ReuploadFile) - reuploadFile(ctx: StateContext, action: ReuploadFile) { - const state = ctx.getState(); - const uploadedFile = state.preprintFile.data; - if (!uploadedFile) return EMPTY; - + @Action(DeletePreprintFile) + deletePreprintFile(ctx: StateContext, action: DeletePreprintFile) { ctx.setState(patch({ preprintFile: patch({ isLoading: true }) })); - return this.fileService.updateFileContent(action.file, uploadedFile.links.upload).pipe( - switchMap(() => - uploadedFile.name !== action.file.name - ? this.fileService.renameEntry(uploadedFile.links.upload, action.file.name, 'replace') - : of(null) - ), + return this.fileService.deleteEntry(action.deleteLink).pipe( + tap(() => { + const state = ctx.getState(); + ctx.patchState({ + preprintFile: { + data: null, + isLoading: false, + error: null, + }, + fileSource: PreprintFileSource.None, + preprint: { + ...state.preprint, + data: state.preprint.data + ? { + ...state.preprint.data, + primaryFileId: null, + } + : null, + }, + }); + }), catchError((error) => handleSectionError(ctx, 'preprintFile', error)) ); } @@ -287,7 +301,7 @@ export class PreprintStepperState { copyFileFromProject(ctx: StateContext, action: CopyFileFromProject) { const createdPreprintId = ctx.getState().preprint.data?.id; if (!createdPreprintId) { - return; + return EMPTY; } ctx.setState(patch({ preprintFile: patch({ isLoading: true }) })); @@ -457,7 +471,11 @@ export class PreprintStepperState { @Action(SubmitPreprint) submitPreprint(ctx: StateContext) { - const createdPreprintId = ctx.getState().preprint.data!.id; + const createdPreprintId = ctx.getState().preprint.data?.id; + if (!createdPreprintId) { + return EMPTY; + } + ctx.setState(patch({ preprint: patch({ isSubmitting: true }) })); return this.preprintsService.submitPreprint(createdPreprintId).pipe( diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 7304da4ff..d4e7e7ad4 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1541,13 +1541,8 @@ "success": "Preprint draft successfully deleted." }, "file": { - "note": "Note: You cannot switch options once a file is attached.", "selectFromProject": "Select From Existing OSF Project", "title": "File", - "tooltips": { - "computerDisabled": "Start a new preprint to attach a file from your computer.", - "projectDisabled": "Start a new preprint to attach a file from your project." - }, "uploadDescription": "Please Upload your {{preprintWord}}", "uploadFileButton": "Upload file", "uploadFromComputer": "Upload From Your Computer"