diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index 6590aa1cc..9d432f280 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -1,4 +1,3 @@ export { JsonApiService } from './json-api.service'; -export { LicensesService } from './licenses.service'; export { RequestAccessService } from './request-access.service'; export { UserService } from './user.service'; diff --git a/src/app/core/services/licenses.service.ts b/src/app/core/services/licenses.service.ts deleted file mode 100644 index e7f206b6f..000000000 --- a/src/app/core/services/licenses.service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Observable } from 'rxjs'; - -import { HttpClient } from '@angular/common/http'; -import { inject, Injectable } from '@angular/core'; - -import { LicensesResponseJsonApi } from '@shared/models/license.model'; - -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class LicensesService { - private readonly http = inject(HttpClient); - private readonly baseUrl = environment.apiUrl; - - getAllLicenses(): Observable { - return this.http.get(`${this.baseUrl}/licenses/?page[size]=20`); - } -} diff --git a/src/app/features/preprints/components/index.ts b/src/app/features/preprints/components/index.ts index d654f5720..37952fcb1 100644 --- a/src/app/features/preprints/components/index.ts +++ b/src/app/features/preprints/components/index.ts @@ -1,6 +1,5 @@ export { BrowseBySubjectsComponent } from './browse-by-subjects/browse-by-subjects.component'; export { PreprintServicesComponent } from './preprint-services/preprint-services.component'; -export { TitleAndAbstractStepComponent } from './submit-steps/title-and-abstract-step/title-and-abstract-step.component'; export { AdvisoryBoardComponent } from '@osf/features/preprints/components/advisory-board/advisory-board.component'; export { PreprintsCreatorsFilterComponent } from '@osf/features/preprints/components/filters/preprints-creators-filter/preprints-creators-filter.component'; export { PreprintsDateCreatedFilterComponent } from '@osf/features/preprints/components/filters/preprints-date-created-filter/preprints-date-created-filter.component'; @@ -11,3 +10,6 @@ export { PreprintsFilterChipsComponent } from '@osf/features/preprints/component export { PreprintsHelpDialogComponent } from '@osf/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component'; export { PreprintsResourcesComponent } from '@osf/features/preprints/components/preprints-resources/preprints-resources.component'; export { PreprintsResourcesFiltersComponent } from '@osf/features/preprints/components/preprints-resources-filters/preprints-resources-filters.component'; +export { FileStepComponent } from '@osf/features/preprints/components/submit-steps/file-step/file-step.component'; +export { MetadataComponent } from '@osf/features/preprints/components/submit-steps/metadata/metadata.component'; +export { TitleAndAbstractStepComponent } from '@osf/features/preprints/components/submit-steps/title-and-abstract-step/title-and-abstract-step.component'; diff --git a/src/app/features/preprints/components/submit-steps/file-step/file-step.component.html b/src/app/features/preprints/components/submit-steps/file-step/file-step.component.html index cb4ed9f27..aa47143d0 100644 --- a/src/app/features/preprints/components/submit-steps/file-step/file-step.component.html +++ b/src/app/features/preprints/components/submit-steps/file-step/file-step.component.html @@ -27,18 +27,18 @@

File

[styleClass]="isFileSourceSelected() ? 'w-full cursor-not-allowed' : 'w-full'" [label]="'Select From existing OSF project' | titlecase" severity="secondary" - [disabled]="isFileSourceSelected()" + [disabled]="isFileSourceSelected() || versionFileMode()" [pTooltip]="isFileSourceSelected() ? 'Start a new preprint to attach a file from your project.' : ''" tooltipPosition="top" (click)="selectFileSource(PreprintFileSource.Project)" /> -@if (isFileSourceSelected() && selectedFileSource() === PreprintFileSource.Computer) { +@if (selectedFileSource() === PreprintFileSource.Computer) {
@if (!fileUploadLink()) { - } @else if (!preprintFiles().length) { + } @else if ((!preprintFiles().length && !arePreprintFilesLoading()) || versionFileMode()) { File }
- -
- @if (arePreprintFilesLoading()) { - - } @else { - @for (file of preprintFiles(); track file.id) { -
-
- -

{{ file.name }}

-
- - -
- } - } -
} -@if (isFileSourceSelected() && selectedFileSource() === PreprintFileSource.Project) { - +@if (selectedFileSource() === PreprintFileSource.Project && !preprintFiles().length && !arePreprintFilesLoading()) { +

This will make your project public, if it is not already

The projects and components for which you have admin access are listed below.

@@ -83,7 +66,7 @@

File

optionValue="id" [formControl]="projectNameControl" [placeholder]="'Project Title'" - class="w-6" + class="w-12 md:w-6" [editable]="true" styleClass="m-t-24" appendTo="body" @@ -107,8 +90,32 @@

File

} } +@if (!versionFileMode()) { +
+ @if (arePreprintFilesLoading()) { + + } @else { + @for (file of preprintFiles(); track file.id) { +
+
+ +

{{ file.name }}

+
+ + +
+ } + } +
+}
- - + +
diff --git a/src/app/features/preprints/components/submit-steps/file-step/file-step.component.scss b/src/app/features/preprints/components/submit-steps/file-step/file-step.component.scss index f0b6fd3d1..9ee46185f 100644 --- a/src/app/features/preprints/components/submit-steps/file-step/file-step.component.scss +++ b/src/app/features/preprints/components/submit-steps/file-step/file-step.component.scss @@ -1,4 +1,5 @@ @use "assets/styles/mixins" as mix; +@use "assets/styles/variables" as var; .file-source-button { --p-button-secondary-border-color: var(--grey-2); @@ -15,8 +16,13 @@ .file-row { @include mix.flex-center-between; - margin-top: mix.rem(48px); padding: mix.rem(6px) mix.rem(12px); border-bottom: 1px solid var(--grey-2); border-top: 1px solid var(--grey-2); } + +.card { + @media (max-width: var.$breakpoint-sm) { + --p-card-body-padding: 0.75rem; + } +} diff --git a/src/app/features/preprints/components/submit-steps/file-step/file-step.component.ts b/src/app/features/preprints/components/submit-steps/file-step/file-step.component.ts index 451e9a9a5..315e832d2 100644 --- a/src/app/features/preprints/components/submit-steps/file-step/file-step.component.ts +++ b/src/app/features/preprints/components/submit-steps/file-step/file-step.component.ts @@ -27,10 +27,12 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { StringOrNull } from '@core/helpers'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { + CopyFileFromProject, GetAvailableProjects, GetPreprintFilesLinks, GetProjectFiles, GetProjectFilesByLink, + ReuploadFile, SetSelectedPreprintFileSource, SubmitPreprintSelectors, UploadFile, @@ -38,6 +40,7 @@ import { import { FilesTreeActions } from '@osf/features/project/files/models'; import { FilesTreeComponent, IconComponent } from '@shared/components'; import { OsfFile } from '@shared/models'; +import { CustomConfirmationService } from '@shared/services'; @Component({ selector: 'osf-file-step', @@ -59,18 +62,22 @@ import { OsfFile } from '@shared/models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileStepComponent implements OnInit { + private customConfirmationService = inject(CustomConfirmationService); private actions = createDispatchMap({ setSelectedFileSource: SetSelectedPreprintFileSource, getPreprintFilesLinks: GetPreprintFilesLinks, uploadFile: UploadFile, + reuploadFile: ReuploadFile, getAvailableProjects: GetAvailableProjects, getFilesForSelectedProject: GetProjectFiles, getProjectFilesByLink: GetProjectFilesByLink, + copyFileFromProject: CopyFileFromProject, }); private destroyRef = inject(DestroyRef); readonly PreprintFileSource = PreprintFileSource; + createdPreprint = select(SubmitPreprintSelectors.getCreatedPreprint); providerId = select(SubmitPreprintSelectors.getSelectedProviderId); selectedFileSource = select(SubmitPreprintSelectors.getSelectedFileSource); fileUploadLink = select(SubmitPreprintSelectors.getUploadLink); @@ -83,6 +90,8 @@ export class FileStepComponent implements OnInit { selectedProjectId = signal(null); currentFolder = signal(null); + versionFileMode = signal(false); + projectNameControl = new FormControl(null); filesTreeActions: FilesTreeActions = { @@ -99,6 +108,7 @@ export class FileStepComponent implements OnInit { }; nextClicked = output(); + backClicked = output(); isFileSourceSelected = computed(() => { return this.selectedFileSource() !== PreprintFileSource.None; @@ -109,9 +119,12 @@ export class FileStepComponent implements OnInit { this.projectNameControl.valueChanges .pipe(debounceTime(500), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe((value) => { - this.selectedProjectId.set(value); - this.actions.getAvailableProjects(value); + .subscribe((projectNameOrId) => { + if (this.selectedProjectId() === projectNameOrId) { + return; + } + + this.actions.getAvailableProjects(projectNameOrId); }); } @@ -124,10 +137,14 @@ export class FileStepComponent implements OnInit { } backButtonClicked() { - //[RNi] TODO: implement logic of going back to the previous step + this.backClicked.emit(); } nextButtonClicked() { + if (!this.createdPreprint()?.primaryFileId) { + return; + } + this.nextClicked.emit(); } @@ -136,7 +153,12 @@ export class FileStepComponent implements OnInit { const file = input.files?.[0]; if (!file) return; - this.actions.uploadFile(file); + if (this.versionFileMode()) { + this.versionFileMode.set(false); + this.actions.reuploadFile(file); + } else { + this.actions.uploadFile(file); + } } @HostListener('window:beforeunload', ['$event']) @@ -150,10 +172,24 @@ export class FileStepComponent implements OnInit { return; } + this.selectedProjectId.set(event.value); this.actions.getFilesForSelectedProject(event.value); } selectProjectFile(file: OsfFile) { - //[RNi] TODO: implement logic of linking preprint to that file + this.actions.copyFileFromProject(file); + } + + versionFile() { + this.customConfirmationService.confirmContinue({ + headerKey: 'Add a new preprint file', + messageKey: + 'This will allow a new version of the preprint file to be uploaded to the preprint. The existing file will be retained as a version of the preprint.', + onConfirm: () => { + this.versionFileMode.set(true); + this.actions.setSelectedFileSource(PreprintFileSource.None); + }, + onReject: () => null, + }); } } diff --git a/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.html b/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.html new file mode 100644 index 000000000..944393108 --- /dev/null +++ b/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.html @@ -0,0 +1,32 @@ + +

{{ 'project.overview.metadata.contributors' | translate }}

+
+ + + Warning: Changing your permissions will prevent you from editing your draft. + +
+ +
+ @if (hasChanges) { +
+ + +
+ } + +
+
diff --git a/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.scss b/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.spec.ts b/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.spec.ts new file mode 100644 index 000000000..fb45f2cde --- /dev/null +++ b/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ContributorsComponent } from './contributors.component'; + +describe('ContributorsComponent', () => { + let component: ContributorsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ContributorsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ContributorsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.ts b/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.ts new file mode 100644 index 000000000..9980a2a29 --- /dev/null +++ b/src/app/features/preprints/components/submit-steps/metadata/contributors/contributors.component.ts @@ -0,0 +1,191 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { DialogService } from 'primeng/dynamicdialog'; +import { Message } from 'primeng/message'; +import { TableModule } from 'primeng/table'; + +import { filter, forkJoin } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { + AddContributor, + DeleteContributor, + FetchContributors, + SubmitPreprintSelectors, + UpdateContributor, +} from '@osf/features/preprints/store/submit-preprint'; +import { EducationHistoryDialogComponent, EmploymentHistoryDialogComponent } from '@osf/shared/components'; +import { + AddContributorDialogComponent, + AddUnregisteredContributorDialogComponent, + ContributorsListComponent, +} from '@osf/shared/components/contributors'; +import { AddContributorType } from '@osf/shared/components/contributors/enums'; +import { ContributorDialogAddModel, ContributorModel } from '@osf/shared/components/contributors/models'; +import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { findChangedItems } from '@osf/shared/utils'; + +@Component({ + selector: 'osf-preprint-contributors', + imports: [FormsModule, TableModule, ContributorsListComponent, TranslatePipe, Card, Button, Message], + templateUrl: './contributors.component.html', + styleUrl: './contributors.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService], +}) +export class ContributorsComponent implements OnInit { + readonly destroyRef = inject(DestroyRef); + readonly translateService = inject(TranslateService); + readonly dialogService = inject(DialogService); + readonly toastService = inject(ToastService); + readonly customConfirmationService = inject(CustomConfirmationService); + + protected initialContributors = select(SubmitPreprintSelectors.getContributors); + protected contributors = signal([]); + + protected readonly isContributorsLoading = select(SubmitPreprintSelectors.areContributorsLoading); + + protected actions = createDispatchMap({ + getContributors: FetchContributors, + deleteContributor: DeleteContributor, + updateContributor: UpdateContributor, + addContributor: AddContributor, + }); + + get hasChanges(): boolean { + return JSON.stringify(this.initialContributors()) !== JSON.stringify(this.contributors()); + } + + constructor() { + effect(() => { + this.contributors.set(JSON.parse(JSON.stringify(this.initialContributors()))); + }); + } + + ngOnInit(): void { + this.actions.getContributors(); + } + + cancel() { + this.contributors.set(JSON.parse(JSON.stringify(this.initialContributors()))); + } + + save() { + const updatedContributors = findChangedItems(this.initialContributors(), this.contributors(), 'id'); + + const updateRequests = updatedContributors.map((payload) => this.actions.updateContributor(payload)); + + forkJoin(updateRequests).subscribe(() => { + this.toastService.showSuccess('project.contributors.toastMessages.multipleUpdateSuccessMessage'); + }); + } + + openEmploymentHistory(contributor: ContributorModel) { + this.dialogService.open(EmploymentHistoryDialogComponent, { + width: '552px', + data: contributor.employment, + focusOnShow: false, + header: this.translateService.instant('project.contributors.table.headers.employment'), + closeOnEscape: true, + modal: true, + closable: true, + }); + } + + openEducationHistory(contributor: ContributorModel) { + this.dialogService.open(EducationHistoryDialogComponent, { + width: '552px', + data: contributor.education, + focusOnShow: false, + header: this.translateService.instant('project.contributors.table.headers.education'), + closeOnEscape: true, + modal: true, + closable: true, + }); + } + + openAddContributorDialog() { + const addedContributorIds = this.initialContributors().map((x) => x.userId); + + this.dialogService + .open(AddContributorDialogComponent, { + width: '448px', + data: addedContributorIds, + focusOnShow: false, + header: this.translateService.instant('project.contributors.addDialog.addRegisteredContributor'), + closeOnEscape: true, + modal: true, + closable: true, + }) + .onClose.pipe( + filter((res: ContributorDialogAddModel) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Unregistered) { + this.openAddUnregisteredContributorDialog(); + } else { + const addRequests = res.data.map((payload) => this.actions.addContributor(payload)); + + forkJoin(addRequests).subscribe(() => { + this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage'); + }); + } + }); + } + + openAddUnregisteredContributorDialog() { + this.dialogService + .open(AddUnregisteredContributorDialogComponent, { + width: '448px', + focusOnShow: false, + header: this.translateService.instant('project.contributors.addDialog.addUnregisteredContributor'), + closeOnEscape: true, + modal: true, + closable: true, + }) + .onClose.pipe( + filter((res: ContributorDialogAddModel) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Registered) { + this.openAddContributorDialog(); + } else { + const successMessage = this.translateService.instant('project.contributors.toastMessages.addSuccessMessage'); + const params = { name: res.data[0].fullName }; + + this.actions.addContributor(res.data[0]).subscribe({ + next: () => this.toastService.showSuccess(successMessage, params), + }); + } + }); + } + + removeContributor(contributor: ContributorModel) { + this.customConfirmationService.confirmDelete({ + headerKey: 'project.contributors.removeDialog.title', + messageKey: 'project.contributors.removeDialog.message', + messageParams: { name: contributor.fullName }, + acceptLabelKey: 'common.buttons.remove', + onConfirm: () => { + this.actions + .deleteContributor(contributor.userId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => + this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { + name: contributor.fullName, + }), + }); + }, + }); + } +} diff --git a/src/app/features/preprints/components/submit-steps/metadata/metadata.component.html b/src/app/features/preprints/components/submit-steps/metadata/metadata.component.html new file mode 100644 index 000000000..9c8bc9aac --- /dev/null +++ b/src/app/features/preprints/components/submit-steps/metadata/metadata.component.html @@ -0,0 +1,75 @@ +

Metadata

+ +
+ +
+ +
+ +
+ + +
+

Publication DOI

+ + + @let doiControl = metadataForm.controls['doi']; + @if (doiControl.errors?.['required'] && (doiControl.touched || doiControl.dirty)) { + + {{ INPUT_VALIDATION_MESSAGES.required | translate }} + + } + @if (doiControl.errors?.['pattern'] && (doiControl.touched || doiControl.dirty)) { + Please use a valid DOI format (10.xxxx/xxxxx) + + } +
+
+ + +

Publication Date (optional)

+ + + + + + +
+ + +

Publication Citation (optional)

+ + +
+ +
+ + +
diff --git a/src/app/features/preprints/components/submit-steps/metadata/metadata.component.scss b/src/app/features/preprints/components/submit-steps/metadata/metadata.component.scss new file mode 100644 index 000000000..243cc50eb --- /dev/null +++ b/src/app/features/preprints/components/submit-steps/metadata/metadata.component.scss @@ -0,0 +1,7 @@ +@use "assets/styles/variables" as var; + +.card { + @media (max-width: var.$breakpoint-sm) { + --p-card-body-padding: 0.75rem; + } +} diff --git a/src/app/features/preprints/components/submit-steps/metadata/metadata.component.spec.ts b/src/app/features/preprints/components/submit-steps/metadata/metadata.component.spec.ts new file mode 100644 index 000000000..311ac4e9f --- /dev/null +++ b/src/app/features/preprints/components/submit-steps/metadata/metadata.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MetadataComponent } from './metadata.component'; + +describe('MetadataComponent', () => { + let component: MetadataComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MetadataComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MetadataComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/submit-steps/metadata/metadata.component.ts b/src/app/features/preprints/components/submit-steps/metadata/metadata.component.ts new file mode 100644 index 000000000..53f574891 --- /dev/null +++ b/src/app/features/preprints/components/submit-steps/metadata/metadata.component.ts @@ -0,0 +1,119 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { DatePicker } from 'primeng/datepicker'; +import { InputText } from 'primeng/inputtext'; +import { Message } from 'primeng/message'; +import { Tooltip } from 'primeng/tooltip'; + +import { ChangeDetectionStrategy, Component, HostListener, OnInit, output } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { formInputLimits } from '@osf/features/preprints/constants'; +import { MetadataForm, Preprint } from '@osf/features/preprints/models'; +import { + CreatePreprint, + FetchLicenses, + SaveLicense, + SubmitPreprintSelectors, + UpdatePreprint, +} from '@osf/features/preprints/store/submit-preprint'; +import { IconComponent, LicenseComponent, TextInputComponent } from '@shared/components'; +import { INPUT_VALIDATION_MESSAGES } from '@shared/constants'; +import { License, LicenseOptions } from '@shared/models'; +import { CustomValidators, findChangedFields } from '@shared/utils'; + +import { ContributorsComponent } from './contributors/contributors.component'; + +@Component({ + selector: 'osf-preprint-metadata', + imports: [ + ContributorsComponent, + Button, + Card, + ReactiveFormsModule, + Message, + TranslatePipe, + DatePicker, + IconComponent, + InputText, + TextInputComponent, + Tooltip, + LicenseComponent, + ], + templateUrl: './metadata.component.html', + styleUrl: './metadata.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetadataComponent implements OnInit { + private actions = createDispatchMap({ + createPreprint: CreatePreprint, + updatePreprint: UpdatePreprint, + fetchLicenses: FetchLicenses, + saveLicense: SaveLicense, + }); + + protected metadataForm!: FormGroup; + protected inputLimits = formInputLimits; + protected readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + + licences = select(SubmitPreprintSelectors.getLicenses); + createdPreprint = select(SubmitPreprintSelectors.getCreatedPreprint); + nextClicked = output(); + + ngOnInit() { + this.actions.fetchLicenses(); + this.initForm(); + } + + initForm() { + const publicationDate = this.createdPreprint()?.originalPublicationDate; + this.metadataForm = new FormGroup({ + doi: new FormControl(this.createdPreprint()?.doi || '', { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed(), Validators.pattern(this.inputLimits.doi.pattern)], + }), + originalPublicationDate: new FormControl(publicationDate ? new Date(publicationDate) : null, { + nonNullable: false, + validators: [], + }), + customPublicationCitation: new FormControl(this.createdPreprint()?.customPublicationCitation || null, { + nonNullable: false, + validators: [Validators.maxLength(this.inputLimits.citation.maxLength)], + }), + }); + } + + nextButtonClicked() { + if (this.metadataForm.invalid) { + return; + } + + const model = this.metadataForm.value; + + const changedFields = findChangedFields(model, this.createdPreprint()!); + + this.actions.updatePreprint(this.createdPreprint()!.id, changedFields).subscribe({ + complete: () => { + this.nextClicked.emit(); + }, + }); + } + + @HostListener('window:beforeunload', ['$event']) + public onBeforeUnload($event: BeforeUnloadEvent): boolean { + $event.preventDefault(); + return false; + } + + createLicense(licenseDetails: { id: string; licenseOptions: LicenseOptions }) { + this.actions.saveLicense(licenseDetails.id, licenseDetails.licenseOptions); + } + + selectLicense(license: License) { + this.actions.saveLicense(license.id); + } +} diff --git a/src/app/features/preprints/components/submit-steps/title-and-abstract-step/title-and-abstract-step.component.html b/src/app/features/preprints/components/submit-steps/title-and-abstract-step/title-and-abstract-step.component.html index 517e1b15f..9a8c1520c 100644 --- a/src/app/features/preprints/components/submit-steps/title-and-abstract-step/title-and-abstract-step.component.html +++ b/src/app/features/preprints/components/submit-steps/title-and-abstract-step/title-and-abstract-step.component.html @@ -1,9 +1,13 @@

Title and Abstract

- +
- +
@@ -36,14 +40,14 @@

Title and Abstract

; description: FormControl; } + +export interface MetadataForm { + doi: FormControl; + originalPublicationDate: FormControl; + customPublicationCitation: FormControl; +} diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html index d6f7264c2..da30ac767 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html @@ -30,11 +30,17 @@

{{ 'Add a ' + preprintProvider()!.preprintWor
@switch (currentStep()) { - @case (0) { - + @case (SubmitStepsEnum.TitleAndAbstract) { + } - @case (1) { - + @case (SubmitStepsEnum.File) { + + } + @case (SubmitStepsEnum.Metadata) { + } @default {

No such step

diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts index 9ee1edfb4..1b62fbbcc 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts @@ -17,15 +17,15 @@ import { import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; -import { TitleAndAbstractStepComponent } from '@osf/features/preprints/components'; -import { FileStepComponent } from '@osf/features/preprints/components/submit-steps/file-step/file-step.component'; +import { + FileStepComponent, + MetadataComponent, + TitleAndAbstractStepComponent, +} from '@osf/features/preprints/components'; import { submitPreprintSteps } from '@osf/features/preprints/constants'; +import { SubmitSteps } from '@osf/features/preprints/enums'; import { BrandService } from '@osf/features/preprints/services'; -import { - GetHighlightedSubjectsByProviderId, - GetPreprintProviderById, - PreprintsSelectors, -} from '@osf/features/preprints/store/preprints'; +import { GetPreprintProviderById, PreprintsSelectors } from '@osf/features/preprints/store/preprints'; import { ResetStateAndDeletePreprint, SetSelectedPreprintProviderId, @@ -35,7 +35,7 @@ import { BrowserTabHelper, HeaderStyleHelper, IS_WEB } from '@shared/utils'; @Component({ selector: 'osf-submit-preprint-stepper', - imports: [Skeleton, StepperComponent, TitleAndAbstractStepComponent, FileStepComponent], + imports: [Skeleton, StepperComponent, TitleAndAbstractStepComponent, FileStepComponent, MetadataComponent], templateUrl: './submit-preprint-stepper.component.html', styleUrl: './submit-preprint-stepper.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -49,16 +49,16 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy { private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, - getHighlightedSubjectsByProviderId: GetHighlightedSubjectsByProviderId, setSelectedPreprintProviderId: SetSelectedPreprintProviderId, resetStateAndDeletePreprint: ResetStateAndDeletePreprint, }); + readonly SubmitStepsEnum = SubmitSteps; readonly submitPreprintSteps = submitPreprintSteps; preprintProvider = select(PreprintsSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintsSelectors.isPreprintProviderDetailsLoading); - currentStep = signal(1); + currentStep = signal(0); isWeb = toSignal(inject(IS_WEB)); constructor() { diff --git a/src/app/features/preprints/services/contributors.service.ts b/src/app/features/preprints/services/contributors.service.ts new file mode 100644 index 000000000..ace44a5f5 --- /dev/null +++ b/src/app/features/preprints/services/contributors.service.ts @@ -0,0 +1,50 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiResponse } from '@core/models'; +import { JsonApiService } from '@core/services'; +import { AddContributorType } from '@shared/components/contributors/enums'; +import { ContributorsMapper } from '@shared/components/contributors/mappers'; +import { ContributorAddModel, ContributorModel, ContributorResponse } from '@shared/components/contributors/models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class ContributorsService { + private jsonApiService = inject(JsonApiService); + private apiUrl = environment.apiUrl; + + getContributors(preprintId: string): Observable { + return this.jsonApiService + .get>(`${this.apiUrl}/preprints/${preprintId}/contributors/`) + .pipe(map((contributors) => ContributorsMapper.fromResponse(contributors.data))); + } + + addContributor(preprintId: string, data: ContributorAddModel): Observable { + const baseUrl = `${this.apiUrl}/preprints/${preprintId}/contributors/`; + const type = data.id ? AddContributorType.Registered : AddContributorType.Unregistered; + + const contributorData = { data: ContributorsMapper.toContributorAddRequest(data, type) }; + + return this.jsonApiService + .post(baseUrl, contributorData) + .pipe(map((contributor) => ContributorsMapper.fromContributorResponse(contributor))); + } + + updateContributor(preprintId: string, data: ContributorModel): Observable { + const baseUrl = `${environment.apiUrl}/preprints/${preprintId}/contributors/${data.userId}`; + + const contributorData = { data: ContributorsMapper.toContributorAddRequest(data) }; + + return this.jsonApiService + .patch(baseUrl, contributorData) + .pipe(map((contributor) => ContributorsMapper.fromContributorResponse(contributor))); + } + + deleteContributor(preprintId: string, contributorId: string): Observable { + return this.jsonApiService.delete(`${this.apiUrl}/preprints/${preprintId}/contributors/${contributorId}`); + } +} diff --git a/src/app/features/preprints/services/index.ts b/src/app/features/preprints/services/index.ts index d93e065fa..4c00c2c22 100644 --- a/src/app/features/preprints/services/index.ts +++ b/src/app/features/preprints/services/index.ts @@ -1,3 +1,4 @@ export { BrandService } from './brand.service'; +export { ContributorsService } from './contributors.service'; export { PreprintsService } from './preprints.service'; export { PreprintsFiltersOptionsService } from './preprints-resource-filters.service'; diff --git a/src/app/features/preprints/services/licenses.service.ts b/src/app/features/preprints/services/licenses.service.ts new file mode 100644 index 000000000..06b62098c --- /dev/null +++ b/src/app/features/preprints/services/licenses.service.ts @@ -0,0 +1,64 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { ApiData } from '@core/models'; +import { JsonApiService } from '@core/services'; +import { PreprintsMapper } from '@osf/features/preprints/mappers'; +import { + PreprintJsonApi, + PreprintLicensePayloadJsonApi, + PreprintsRelationshipsJsonApi, +} from '@osf/features/preprints/models'; +import { LicensesMapper } from '@shared/mappers'; +import { License, LicenseOptions, LicensesResponseJsonApi } from '@shared/models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class LicensesService { + private apiUrl = environment.apiUrl; + private readonly jsonApiService = inject(JsonApiService); + + getLicenses(providerId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/providers/preprints/${providerId}/licenses/`, { + 'page[size]': 100, + sort: 'name', + }) + .pipe(map((licenses) => LicensesMapper.fromLicensesResponse(licenses))); + } + + updatePreprintLicense(preprintId: string, licenseId: string, licenseOptions?: LicenseOptions) { + const payload: PreprintLicensePayloadJsonApi = { + data: { + type: 'preprints', + id: preprintId, + relationships: { + license: { + data: { + id: licenseId, + type: 'licenses', + }, + }, + }, + attributes: { + ...(licenseOptions && { + license_record: { + copyright_holders: [licenseOptions.copyrightHolders], + year: licenseOptions.year, + }, + }), + }, + }, + }; + + return this.jsonApiService + .patch< + ApiData + >(`${this.apiUrl}/preprints/${preprintId}/`, payload) + .pipe(map((response) => PreprintsMapper.fromPreprintJsonApi(response))); + } +} diff --git a/src/app/features/preprints/services/preprints.service.ts b/src/app/features/preprints/services/preprints.service.ts index 117028d1f..a5e146bd0 100644 --- a/src/app/features/preprints/services/preprints.service.ts +++ b/src/app/features/preprints/services/preprints.service.ts @@ -33,6 +33,9 @@ export class PreprintsService { private domainToApiFieldMap: Record = { title: 'title', description: 'description', + originalPublicationDate: 'original_publication_date', + doi: 'doi', + customPublicationCitation: 'custom_publication_citation', }; getPreprintProviderById(id: string): Observable { diff --git a/src/app/features/preprints/store/submit-preprint/submit-preprint.actions.ts b/src/app/features/preprints/store/submit-preprint/submit-preprint.actions.ts index f5bdf2edc..9c3b26913 100644 --- a/src/app/features/preprints/store/submit-preprint/submit-preprint.actions.ts +++ b/src/app/features/preprints/store/submit-preprint/submit-preprint.actions.ts @@ -1,6 +1,8 @@ import { StringOrNull } from '@core/helpers'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { Preprint } from '@osf/features/preprints/models'; +import { ContributorAddModel, ContributorModel } from '@shared/components/contributors/models'; +import { LicenseOptions, OsfFile } from '@shared/models'; export class SetSelectedPreprintProviderId { static readonly type = '[Submit Preprint] Set Selected Preprint Provider Id'; @@ -43,6 +45,18 @@ export class UploadFile { constructor(public file: File) {} } +export class ReuploadFile { + static readonly type = '[Submit Preprint] Reupload File'; + + constructor(public file: File) {} +} + +export class CopyFileFromProject { + static readonly type = '[Submit Preprint] Copy File From Project'; + + constructor(public file: OsfFile) {} +} + export class GetPreprintFiles { static readonly type = '[Submit Preprint] Get Preprint Files'; } @@ -65,6 +79,41 @@ export class GetProjectFilesByLink { constructor(public filesLink: string) {} } +export class FetchContributors { + static readonly type = '[Submit Preprint] Fetch Contributors'; +} + +export class AddContributor { + static readonly type = '[Submit Preprint] Add Contributor'; + + constructor(public contributor: ContributorAddModel) {} +} + +export class UpdateContributor { + static readonly type = '[Submit Preprint] Update Contributor'; + + constructor(public contributor: ContributorModel) {} +} + +export class DeleteContributor { + static readonly type = '[Submit Preprint] Delete Contributor'; + + constructor(public userId: string) {} +} + +export class FetchLicenses { + static readonly type = '[Submit Preprint] Fetch Licenses'; +} + +export class SaveLicense { + static readonly type = '[Submit Preprint] Save License'; + + constructor( + public licenseId: string, + public licenseOptions?: LicenseOptions + ) {} +} + export class ResetStateAndDeletePreprint { static readonly type = '[Submit Preprint] Reset State And Delete Preprint'; } diff --git a/src/app/features/preprints/store/submit-preprint/submit-preprint.model.ts b/src/app/features/preprints/store/submit-preprint/submit-preprint.model.ts index f26210c44..8a3ea7575 100644 --- a/src/app/features/preprints/store/submit-preprint/submit-preprint.model.ts +++ b/src/app/features/preprints/store/submit-preprint/submit-preprint.model.ts @@ -1,7 +1,9 @@ import { StringOrNull } from '@core/helpers'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { Preprint, PreprintFilesLinks } from '@osf/features/preprints/models'; +import { ContributorModel } from '@shared/components/contributors/models'; import { AsyncStateModel, IdName, OsfFile } from '@shared/models'; +import { License } from '@shared/models/license.model'; export interface SubmitPreprintStateModel { selectedProviderId: StringOrNull; @@ -11,4 +13,6 @@ export interface SubmitPreprintStateModel { preprintFiles: AsyncStateModel; availableProjects: AsyncStateModel; projectFiles: AsyncStateModel; + contributors: AsyncStateModel; + licenses: AsyncStateModel; } diff --git a/src/app/features/preprints/store/submit-preprint/submit-preprint.selectors.ts b/src/app/features/preprints/store/submit-preprint/submit-preprint.selectors.ts index 9267ceb23..5eb2a2d39 100644 --- a/src/app/features/preprints/store/submit-preprint/submit-preprint.selectors.ts +++ b/src/app/features/preprints/store/submit-preprint/submit-preprint.selectors.ts @@ -57,4 +57,19 @@ export class SubmitPreprintSelectors { static areProjectFilesLoading(state: SubmitPreprintStateModel) { return state.projectFiles.isLoading; } + + @Selector([SubmitPreprintState]) + static getContributors(state: SubmitPreprintStateModel) { + return state.contributors.data; + } + + @Selector([SubmitPreprintState]) + static areContributorsLoading(state: SubmitPreprintStateModel) { + return state.contributors.isLoading; + } + + @Selector([SubmitPreprintState]) + static getLicenses(state: SubmitPreprintStateModel) { + return state.licenses.data; + } } diff --git a/src/app/features/preprints/store/submit-preprint/submit-preprint.state.ts b/src/app/features/preprints/store/submit-preprint/submit-preprint.state.ts index 7e58709dd..7addc2a61 100644 --- a/src/app/features/preprints/store/submit-preprint/submit-preprint.state.ts +++ b/src/app/features/preprints/store/submit-preprint/submit-preprint.state.ts @@ -1,7 +1,7 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { patch } from '@ngxs/store/operators'; +import { insertItem, patch, removeItem, updateItem } from '@ngxs/store/operators'; -import { EMPTY, take, tap, throwError } from 'rxjs'; +import { EMPTY, filter, switchMap, tap, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { HttpEventType } from '@angular/common/http'; @@ -9,21 +9,30 @@ import { inject, Injectable } from '@angular/core'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { Preprint } from '@osf/features/preprints/models'; -import { PreprintsService } from '@osf/features/preprints/services'; +import { ContributorsService, PreprintsService } from '@osf/features/preprints/services'; +import { LicensesService } from '@osf/features/preprints/services/licenses.service'; import { OsfFile } from '@shared/models'; import { FilesService } from '@shared/services'; import { + AddContributor, + CopyFileFromProject, CreatePreprint, + DeleteContributor, + FetchContributors, + FetchLicenses, GetAvailableProjects, GetPreprintFiles, GetPreprintFilesLinks, GetProjectFiles, GetProjectFilesByLink, ResetStateAndDeletePreprint, + ReuploadFile, + SaveLicense, SetSelectedPreprintFileSource, SetSelectedPreprintProviderId, SubmitPreprintStateModel, + UpdateContributor, UpdatePreprint, UploadFile, } from './'; @@ -33,7 +42,7 @@ import { defaults: { selectedProviderId: null, createdPreprint: { - data: null, + data: { id: '6s4jg_v1' } as Preprint, // Temporary default value for testing isLoading: false, error: null, isSubmitting: false, @@ -59,12 +68,24 @@ import { isLoading: false, error: null, }, + contributors: { + data: [], + isLoading: false, + error: null, + }, + licenses: { + data: [], + isLoading: false, + error: null, + }, }, }) @Injectable() export class SubmitPreprintState { private preprintsService = inject(PreprintsService); private fileService = inject(FilesService); + private contributorsService = inject(ContributorsService); + private licensesService = inject(LicensesService); @Action(SetSelectedPreprintProviderId) setSelectedPreprintProviderId(ctx: StateContext, action: SetSelectedPreprintProviderId) { @@ -122,28 +143,42 @@ export class SubmitPreprintState { ctx.setState(patch({ preprintFiles: patch({ isLoading: true }) })); return this.fileService.uploadFileByLink(action.file, state.preprintFilesLinks.data.uploadFileLink).pipe( - tap((event) => { - if (event.type === HttpEventType.Response) { - ctx.dispatch(GetPreprintFiles); - this.preprintsService - .updateFileRelationship(state.createdPreprint.data!.id, event.body!.data.id) - .pipe( - tap((preprint: Preprint) => { - ctx.setState((state: SubmitPreprintStateModel) => ({ - ...state, - createdPreprint: { - ...state.createdPreprint, - data: state.createdPreprint.data - ? { ...state.createdPreprint.data, primaryFileId: preprint.primaryFileId } - : null, - }, - })); - }), - take(1), - catchError((error) => this.handleError(ctx, 'createdPreprint', error)) - ) - .subscribe(); - } + filter((event) => event.type === HttpEventType.Response), + switchMap((event) => { + const file = event.body!.data; + const createdFileId = file.id.split('/')[1]; + ctx.dispatch(new GetPreprintFiles()); + + return this.preprintsService.updateFileRelationship(state.createdPreprint.data!.id, createdFileId).pipe( + tap((preprint: Preprint) => { + ctx.patchState({ + createdPreprint: { + ...ctx.getState().createdPreprint, + data: { + ...ctx.getState().createdPreprint.data!, + primaryFileId: preprint.primaryFileId, + }, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'createdPreprint', error)) + ); + }) + ); + } + + @Action(ReuploadFile) + reuploadFile(ctx: StateContext, action: ReuploadFile) { + const state = ctx.getState(); + const uploadedFile = state.preprintFiles.data[0]; + if (!uploadedFile) return EMPTY; + + ctx.setState(patch({ preprintFiles: patch({ isLoading: true }) })); + + return this.fileService.updateFileContent(action.file, uploadedFile.links.upload).pipe( + switchMap(() => this.fileService.renameEntry(uploadedFile.links.upload, action.file.name, 'replace')), + tap(() => { + ctx.dispatch(GetPreprintFiles); }) ); } @@ -205,7 +240,16 @@ export class SubmitPreprintState { }) ); }), - catchError((error) => this.handleError(ctx, 'projectFiles', error)) + catchError((error) => { + ctx.setState( + patch({ + preprintFiles: patch({ + data: [], + }), + }) + ); + return this.handleError(ctx, 'projectFiles', error); + }) ); } @@ -261,6 +305,16 @@ export class SubmitPreprintState { isLoading: false, error: null, }, + contributors: { + data: [], + isLoading: false, + error: null, + }, + licenses: { + data: [], + isLoading: false, + error: null, + }, }); if (createdPreprintId) { return this.preprintsService.deletePreprint(createdPreprintId); @@ -276,6 +330,150 @@ export class SubmitPreprintState { }); } + @Action(CopyFileFromProject) + copyFileFromProject(ctx: StateContext, action: CopyFileFromProject) { + const createdPreprintId = ctx.getState().createdPreprint.data?.id; + if (!createdPreprintId) { + return; + } + + ctx.setState(patch({ preprintFiles: patch({ isLoading: true }) })); + return this.fileService + .copyFileToAnotherLocation(action.file.links.move, action.file.provider, createdPreprintId) + .pipe( + switchMap((file: OsfFile) => { + ctx.dispatch(new GetPreprintFiles()); + + const fileIdAfterCopy = file.id.split('/')[1]; + + return this.preprintsService.updateFileRelationship(createdPreprintId, fileIdAfterCopy).pipe( + tap((preprint: Preprint) => { + ctx.patchState({ + createdPreprint: { + ...ctx.getState().createdPreprint, + data: { + ...ctx.getState().createdPreprint.data!, + primaryFileId: preprint.primaryFileId, + }, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'createdPreprint', error)) + ); + }), + catchError((error) => this.handleError(ctx, 'preprintFiles', error)) + ); + } + + @Action(FetchContributors) + fetchContributors(ctx: StateContext) { + const createdPreprint = ctx.getState().createdPreprint.data; + if (!createdPreprint) { + return; + } + + ctx.setState(patch({ contributors: patch({ isLoading: true }) })); + + return this.contributorsService.getContributors(createdPreprint.id).pipe( + tap((contributors) => { + ctx.setState(patch({ contributors: patch({ isLoading: false, data: contributors }) })); + }), + catchError((error) => this.handleError(ctx, 'contributors', error)) + ); + } + + @Action(AddContributor) + addContributor(ctx: StateContext, action: AddContributor) { + const createdPreprint = ctx.getState().createdPreprint.data; + if (!createdPreprint) { + return; + } + + ctx.setState(patch({ contributors: patch({ isLoading: true }) })); + + return this.contributorsService.addContributor(createdPreprint.id, action.contributor).pipe( + tap((contributor) => { + ctx.setState(patch({ contributors: patch({ isLoading: false, data: insertItem(contributor) }) })); + }), + catchError((error) => this.handleError(ctx, 'contributors', error)) + ); + } + + @Action(UpdateContributor) + updateContributor(ctx: StateContext, action: UpdateContributor) { + const createdPreprint = ctx.getState().createdPreprint.data; + if (!createdPreprint) { + return; + } + + ctx.setState(patch({ contributors: patch({ isLoading: true }) })); + + return this.contributorsService.updateContributor(createdPreprint.id, action.contributor).pipe( + tap((contributor) => { + ctx.setState( + patch({ + contributors: patch({ + isLoading: false, + data: updateItem((item) => item.id === action.contributor.id, contributor), + }), + }) + ); + }), + catchError((error) => this.handleError(ctx, 'contributors', error)) + ); + } + + @Action(DeleteContributor) + deleteContributor(ctx: StateContext, action: DeleteContributor) { + const createdPreprint = ctx.getState().createdPreprint.data; + if (!createdPreprint) { + return; + } + + ctx.setState(patch({ contributors: patch({ isLoading: true }) })); + + return this.contributorsService.deleteContributor(createdPreprint.id, action.userId).pipe( + tap(() => { + ctx.setState( + patch({ + contributors: patch({ + isLoading: false, + data: removeItem((item) => action.userId === item.userId), + }), + }) + ); + }), + catchError((error) => this.handleError(ctx, 'contributors', error)) + ); + } + + @Action(FetchLicenses) + fetchLicenses(ctx: StateContext) { + const providerId = ctx.getState().selectedProviderId; + if (!providerId) return; + ctx.setState(patch({ licenses: patch({ isLoading: true }) })); + + return this.licensesService.getLicenses(providerId).pipe( + tap((licenses) => { + ctx.setState(patch({ licenses: patch({ isLoading: false, data: licenses }) })); + }), + catchError((error) => this.handleError(ctx, 'licenses', error)) + ); + } + + @Action(SaveLicense) + saveLicense(ctx: StateContext, action: SaveLicense) { + const createdPreprintId = ctx.getState().createdPreprint.data!.id; + ctx.setState(patch({ createdPreprint: patch({ isSubmitting: true }) })); + + return this.licensesService.updatePreprintLicense(createdPreprintId, action.licenseId, action.licenseOptions).pipe( + tap((preprint) => { + ctx.setState(patch({ createdPreprint: patch({ isSubmitting: false, data: preprint }) })); + }), + catchError((error) => this.handleError(ctx, 'createdPreprint', error)) + ); + } + private handleError( ctx: StateContext, section: keyof SubmitPreprintStateModel, diff --git a/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.ts b/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.ts index ba6f220f6..c939898fe 100644 --- a/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.ts +++ b/src/app/features/project/metadata/dialogs/license-dialog/license-dialog.component.ts @@ -52,7 +52,7 @@ export class LicenseDialogComponent implements OnInit { if (currentLicenses) { this.licenseOptions = currentLicenses.map((license: License) => ({ - label: license.attributes.name, + label: license.name, value: license.id, })); } @@ -77,7 +77,7 @@ export class LicenseDialogComponent implements OnInit { const selectedLicense = this.licenses().find((license: License) => license.id === licenseId); if (selectedLicense) { - this.selectedLicenseText.set(selectedLicense.attributes.text); + this.selectedLicenseText.set(selectedLicense.text); } else { this.selectedLicenseText.set(''); } diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index 0ef79a98b..4609a2226 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -12,6 +12,7 @@ export { FormSelectComponent } from './form-select/form-select.component'; export { FullScreenLoaderComponent } from './full-screen-loader/full-screen-loader.component'; export { IconComponent } from './icon/icon.component'; export { InfoIconComponent } from './info-icon/info-icon.component'; +export { LicenseComponent } from './license/license.component'; export { LineChartComponent } from './line-chart/line-chart.component'; export { LoadingSpinnerComponent } from './loading-spinner/loading-spinner.component'; export { MarkdownComponent } from './markdown/markdown.component'; diff --git a/src/app/shared/components/license/license.component.html b/src/app/shared/components/license/license.component.html new file mode 100644 index 000000000..da48a4ce0 --- /dev/null +++ b/src/app/shared/components/license/license.component.html @@ -0,0 +1,67 @@ + +

{{ 'shared.license.title' | translate }}

+ +

{{ 'shared.license.description' | translate }}

+

+ {{ 'shared.license.helpText' | translate }} + {{ 'common.links.helpGuide' | translate }}. +

+ + + @if (selectedLicense()) { + + @if (selectedLicense()!.requiredFields.length) { +
+
+ + +
+ + + } + +

+ +

+ + @if (selectedLicense()!.requiredFields.length) { +
+ + +
+ } + } +
diff --git a/src/app/shared/components/license/license.component.scss b/src/app/shared/components/license/license.component.scss new file mode 100644 index 000000000..7f863186d --- /dev/null +++ b/src/app/shared/components/license/license.component.scss @@ -0,0 +1,4 @@ +.highlight-block { + padding: 0.5rem; + background-color: var(--bg-blue-2); +} diff --git a/src/app/shared/components/license/license.component.spec.ts b/src/app/shared/components/license/license.component.spec.ts new file mode 100644 index 000000000..58410bcd4 --- /dev/null +++ b/src/app/shared/components/license/license.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LicenseComponent } from '@shared/components'; + +describe('LicenseComponent', () => { + let component: LicenseComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LicenseComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(LicenseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/license/license.component.ts b/src/app/shared/components/license/license.component.ts new file mode 100644 index 000000000..27eb3f58f --- /dev/null +++ b/src/app/shared/components/license/license.component.ts @@ -0,0 +1,124 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { DatePicker } from 'primeng/datepicker'; +import { Divider } from 'primeng/divider'; +import { Select } from 'primeng/select'; + +import { ChangeDetectionStrategy, Component, effect, input, model, output, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { StringOrNullOrUndefined } from '@core/helpers'; +import { InputLimits } from '@shared/constants'; +import { License, LicenseOptions } from '@shared/models'; +import { InterpolatePipe } from '@shared/pipes'; +import { CustomValidators } from '@shared/utils'; + +import { LicenseForm } from '../license/models'; +import { TextInputComponent } from '../text-input/text-input.component'; +import { TruncatedTextComponent } from '../truncated-text/truncated-text.component'; + +@Component({ + selector: 'osf-license', + imports: [ + Card, + TranslatePipe, + Select, + FormsModule, + Divider, + DatePicker, + TextInputComponent, + ReactiveFormsModule, + Button, + TruncatedTextComponent, + InterpolatePipe, + ], + templateUrl: './license.component.html', + styleUrl: './license.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LicenseComponent { + selectedLicenseId = input(null); + selectedLicenseOptions = input(null); + licenses = input.required(); + selectedLicense = model(null); + createLicense = output<{ id: string; licenseOptions: LicenseOptions }>(); + selectLicense = output(); + protected inputLimits = InputLimits; + saveButtonDisabled = signal(false); + + currentYear = new Date(); + licenseForm = new FormGroup({ + year: new FormControl(this.currentYear.getFullYear().toString(), { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed()], + }), + copyrightHolders: new FormControl('', { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed()], + }), + }); + licenseFormValue = toSignal(this.licenseForm.valueChanges); + + constructor() { + effect(() => { + const license = this.licenses().find((l) => l.id === this.selectedLicenseId()); + this.selectedLicense.set(license || null); + }); + + effect(() => { + const options = this.selectedLicenseOptions(); + if (this.selectedLicenseOptions()) { + this.licenseForm.patchValue({ + year: options!.year, + copyrightHolders: options!.copyrightHolders, + }); + } + }); + + effect(() => { + const licenseOptionsInput = this.selectedLicenseOptions(); + const licenseOptionsFormValue = this.licenseFormValue(); + + if (!this.selectedLicense() || !this.selectedLicense()?.requiredFields.length) { + return; + } + + if (JSON.stringify(licenseOptionsInput) === JSON.stringify(licenseOptionsFormValue)) { + this.saveButtonDisabled.set(true); + } else { + this.saveButtonDisabled.set(false); + } + }); + } + + onSelectLicense(license: License): void { + if (license.requiredFields.length) { + return; + } + + this.selectLicense.emit(license); + } + + saveLicense() { + if (this.licenseForm.invalid) { + return; + } + const selectedLicenseId = this.selectedLicense()!.id; + + const model = this.licenseForm.value as LicenseOptions; + this.createLicense.emit({ + id: selectedLicenseId, + licenseOptions: model, + }); + } + + cancel() { + this.licenseForm.reset({ + year: this.currentYear.getFullYear().toString(), + copyrightHolders: '', + }); + } +} diff --git a/src/app/shared/components/license/models/index.ts b/src/app/shared/components/license/models/index.ts new file mode 100644 index 000000000..10c39d296 --- /dev/null +++ b/src/app/shared/components/license/models/index.ts @@ -0,0 +1 @@ +export * from './license-form.models'; diff --git a/src/app/shared/components/license/models/license-form.models.ts b/src/app/shared/components/license/models/license-form.models.ts new file mode 100644 index 000000000..8757d305f --- /dev/null +++ b/src/app/shared/components/license/models/license-form.models.ts @@ -0,0 +1,6 @@ +import { FormControl } from '@angular/forms'; + +export interface LicenseForm { + copyrightHolders: FormControl; + year: FormControl; +} diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index 8c81d4fe5..5c5cd00dd 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -1,5 +1,6 @@ export * from './addon.mapper'; export * from './filters'; export * from './institutions'; +export * from './licenses.mapper'; export * from './resource-card'; export * from './subjects'; diff --git a/src/app/shared/mappers/licenses.mapper.ts b/src/app/shared/mappers/licenses.mapper.ts new file mode 100644 index 000000000..1e6d83036 --- /dev/null +++ b/src/app/shared/mappers/licenses.mapper.ts @@ -0,0 +1,14 @@ +import { License } from '@shared/models/license.model'; +import { LicensesResponseJsonApi } from '@shared/models/licenses-json-api.model'; + +export class LicensesMapper { + static fromLicensesResponse(response: LicensesResponseJsonApi): License[] { + return response.data.map((item) => ({ + id: item.id, + name: item.attributes.name, + requiredFields: item.attributes.required_fields, + url: item.attributes.url, + text: item.attributes.text, + })); + } +} diff --git a/src/app/shared/models/confirmation-options.model.ts b/src/app/shared/models/confirmation-options.model.ts index e051a3d16..01b0d28e8 100644 --- a/src/app/shared/models/confirmation-options.model.ts +++ b/src/app/shared/models/confirmation-options.model.ts @@ -21,3 +21,15 @@ export interface AcceptConfirmationOptions { onConfirm: () => void; onReject: () => void; } + +export interface ContinueConfirmationOptions { + headerKey: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + headerParams?: any; + messageKey: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messageParams?: any; + acceptLabelKey?: string; + onConfirm: () => void; + onReject: () => void; +} diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index bebcc60f3..a41243b27 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -13,6 +13,8 @@ export * from './id-name.model'; export * from './institutions'; export * from './language-code.model'; export * from './license.model'; +export * from './license.model'; +export * from './licenses-json-api.model'; export * from './metadata-field.model'; export * from './nav-item.model'; export * from './node-response.model'; diff --git a/src/app/shared/models/license.model.ts b/src/app/shared/models/license.model.ts index f10f5f15f..8206e7fb8 100644 --- a/src/app/shared/models/license.model.ts +++ b/src/app/shared/models/license.model.ts @@ -1,34 +1,12 @@ -export interface LicenseAttributes { - name: string; - text: string; - url: string; - required_fields: string[]; -} - -export interface LicenseLinks { - self: string; -} - export interface License { id: string; - type: string; - attributes: LicenseAttributes; - links: LicenseLinks; + name: string; + requiredFields: string[]; + url: string; + text: string; } -export interface LicensesResponseJsonApi { - data: License[]; - links: { - first: string | null; - last: string | null; - prev: string | null; - next: string | null; - meta: { - total: number; - per_page: number; - }; - }; - meta: { - version: string; - }; +export interface LicenseOptions { + copyrightHolders: string; + year: string; } diff --git a/src/app/shared/models/licenses-json-api.model.ts b/src/app/shared/models/licenses-json-api.model.ts new file mode 100644 index 000000000..356f06d0f --- /dev/null +++ b/src/app/shared/models/licenses-json-api.model.ts @@ -0,0 +1,21 @@ +import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '@core/models'; + +export interface LicensesResponseJsonApi { + data: LicenseDataJsonApi[]; + meta: MetaJsonApi; + links: PaginationLinksJsonApi; +} + +export type LicenseDataJsonApi = ApiData; + +export interface LicenseAttributesJsonApi { + name: string; + required_fields: string[]; + url: string; + text: string; +} + +export interface LicenseRecordJsonApi { + copyright_holders: string[]; + year: string; +} diff --git a/src/app/shared/pipes/index.ts b/src/app/shared/pipes/index.ts index 05dc24e77..6e3ada82f 100644 --- a/src/app/shared/pipes/index.ts +++ b/src/app/shared/pipes/index.ts @@ -1,4 +1,5 @@ export { DecodeHtmlPipe } from './decode-html.pipe'; export { FileSizePipe } from './file-size.pipe'; +export { InterpolatePipe } from './interpolate.pipe'; export { MonthYearPipe } from './month-year.pipe'; export { WrapFnPipe } from './wrap-fn.pipe'; diff --git a/src/app/shared/pipes/interpolate.pipe.ts b/src/app/shared/pipes/interpolate.pipe.ts new file mode 100644 index 000000000..9a42f81ec --- /dev/null +++ b/src/app/shared/pipes/interpolate.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'interpolate', +}) +export class InterpolatePipe implements PipeTransform { + transform(template: string, variables: Record): string { + return template.replace(/{{\s*(\w+)\s*}}/g, (_, key) => (variables[key] != null ? variables[key] : '')); + } +} diff --git a/src/app/shared/services/custom-confirmation.service.ts b/src/app/shared/services/custom-confirmation.service.ts index d828a42ff..49028e7d5 100644 --- a/src/app/shared/services/custom-confirmation.service.ts +++ b/src/app/shared/services/custom-confirmation.service.ts @@ -4,7 +4,7 @@ import { ConfirmationService } from 'primeng/api'; import { inject, Injectable } from '@angular/core'; -import { AcceptConfirmationOptions, DeleteConfirmationOptions } from '../models'; +import { AcceptConfirmationOptions, ContinueConfirmationOptions, DeleteConfirmationOptions } from '../models'; @Injectable({ providedIn: 'root', @@ -54,4 +54,27 @@ export class CustomConfirmationService { }, }); } + + confirmContinue(options: ContinueConfirmationOptions): void { + this.confirmationService.confirm({ + header: this.translateService.instant(options.headerKey, options.headerParams), + message: this.translateService.instant(options.messageKey, options.messageParams), + closable: true, + closeOnEscape: false, + acceptButtonProps: { + label: this.translateService.instant(options.acceptLabelKey || 'common.buttons.continue'), + severity: 'danger', + }, + rejectButtonProps: { + label: this.translateService.instant('common.buttons.cancel'), + severity: 'info', + }, + accept: () => { + options.onConfirm(); + }, + reject: () => { + options.onReject(); + }, + }); + } } diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 9db186a4e..fc6a8972f 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -100,6 +100,14 @@ export class FilesService { return this.#jsonApiService.putFile(uploadLink, file, params); } + updateFileContent(file: File, link: string) { + const params = { + kind: 'file', + }; + + return this.#jsonApiService.put(link, file, params); + } + createFolder(resourceId: string, provider: string, folderName: string, folderId?: string): Observable { const params: Record = { kind: 'folder', @@ -123,11 +131,11 @@ export class FilesService { return this.#jsonApiService.delete(link); } - renameEntry(link: string, name: string) { + renameEntry(link: string, name: string, conflict = ''): Observable { const body = { action: 'rename', rename: name, - conflict: '', + conflict, }; return this.#jsonApiService.post(link, body); } @@ -240,4 +248,17 @@ export class FilesService { >(`${environment.apiUrl}/files/${fileGuid}/`, payload) .pipe(map((response) => MapFile(response))); } + + copyFileToAnotherLocation(moveLink: string, provider: string, resourceId: string) { + const body = { + action: 'copy', + conflict: 'replace', + path: '/', + provider, + resource: resourceId, + }; + return this.#jsonApiService + .post>(moveLink, body) + .pipe(map((response) => MapFile(response))); + } } diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index ea718bdae..0bb43c091 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -3,6 +3,7 @@ export { CustomConfirmationService } from './custom-confirmation.service'; export { FilesService } from './files.service'; export { FiltersOptionsService } from './filters-options.service'; export { InstitutionsService } from './institutions.service'; +export { LicensesService } from './licenses.service'; export { LoaderService } from './loader.service'; export { ResourceCardService } from './resource-card.service'; export { SearchService } from './search.service'; diff --git a/src/app/shared/services/licenses.service.ts b/src/app/shared/services/licenses.service.ts new file mode 100644 index 000000000..0b5bd0a01 --- /dev/null +++ b/src/app/shared/services/licenses.service.ts @@ -0,0 +1,23 @@ +import { map, Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { LicensesMapper } from '@shared/mappers'; +import { License, LicensesResponseJsonApi } from '@shared/models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class LicensesService { + private readonly http = inject(HttpClient); + private readonly baseUrl = environment.apiUrl; + + getAllLicenses(): Observable { + return this.http + .get(`${this.baseUrl}/licenses/?page[size]=20`) + .pipe(map((licenses) => LicensesMapper.fromLicensesResponse(licenses))); + } +} diff --git a/src/app/shared/stores/licenses/licenses.state.ts b/src/app/shared/stores/licenses/licenses.state.ts index 6c4e4461a..4cbd08514 100644 --- a/src/app/shared/stores/licenses/licenses.state.ts +++ b/src/app/shared/stores/licenses/licenses.state.ts @@ -5,7 +5,7 @@ import { catchError, tap } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; -import { LicensesService } from '@core/services/licenses.service'; +import { LicensesService } from '@shared/services'; import { LoadAllLicenses } from './licenses.actions'; import { LicensesStateModel } from './licenses.model'; @@ -37,10 +37,10 @@ export class LicensesState { }); return this.licensesService.getAllLicenses().pipe( - tap((response) => { + tap((data) => { ctx.patchState({ licenses: { - data: response.data, + data: data, isLoading: false, error: null, }, diff --git a/src/app/shared/utils/find-changed-fields.ts b/src/app/shared/utils/find-changed-fields.ts new file mode 100644 index 000000000..d023e253d --- /dev/null +++ b/src/app/shared/utils/find-changed-fields.ts @@ -0,0 +1,27 @@ +/** + * Compares a partial form model with a full object and returns only the fields that have changed. + * Useful for detecting which fields in a form differ from the original object state. + * + * Uses `JSON.stringify` for deep equality comparison and handles basic types, arrays, and objects. + * Note: Differences in date formatting or time zones (e.g., missing 'Z' in ISO strings) may cause false positives. + * + * @param formModel - A partial object representing the edited form values. + * @param currentObject - The original full object to compare against. + * @returns A partial object containing only the fields from `formModel` that differ from `currentObject`. + */ +export function findChangedFields(formModel: Partial, currentObject: T): Partial { + const result: Partial = {}; + + for (const key of Object.keys(formModel) as (keyof T)[]) { + const formVal = formModel[key]; + const currentVal = currentObject[key]; + + const isEqual = JSON.stringify(formVal) === JSON.stringify(currentVal); + + if (!isEqual) { + result[key] = formVal; + } + } + + return result; +} diff --git a/src/app/shared/utils/index.ts b/src/app/shared/utils/index.ts index 204b4de20..77bce136c 100644 --- a/src/app/shared/utils/index.ts +++ b/src/app/shared/utils/index.ts @@ -5,6 +5,7 @@ export * from './breakpoints.tokens'; export { BrowserTabHelper } from './browser-tab.helper'; export * from './custom-form-validators.helper'; export * from './default-confirmation-config.helper'; +export * from './find-changed-fields'; export * from './find-changed-items.helper'; export * from './get-resource-types.helper'; export * from './pie-chart-palette'; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b28e70448..2e16b30cd 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -20,6 +20,7 @@ "disconnect": "Disconnect", "revert": "Revert", "next": "Next", + "continue": "Continue", "skip": "Skip", "done": "Done", "select": "Select", @@ -38,12 +39,17 @@ } }, "labels": { - "downloads": "Downloads" + "downloads": "Downloads", + "year": "Year" }, "deleteConfirmation": { "header": "Delete", "message": "Are you sure you want to proceed?" }, + "links": { + "clickHere": "Click here", + "helpGuide": "Help Guide" + }, "placeholder": { "addTag": "Add tags" } @@ -1578,6 +1584,13 @@ "materials": "Materials", "papers": "Papers", "supplements": "Supplements" + }, + "license": { + "title": "License", + "selectLicense": "Select license", + "description": "A license tells others how they can use your work in the future and only applies to the information and files submitted with the registration.", + "helpText": "For more information, see this ", + "copyrightHolders": "Copyright Holders" } }, "pageNotFound": { diff --git a/src/assets/styles/overrides/date-picker.scss b/src/assets/styles/overrides/date-picker.scss new file mode 100644 index 000000000..26196c4aa --- /dev/null +++ b/src/assets/styles/overrides/date-picker.scss @@ -0,0 +1,7 @@ +@use "assets/styles/mixins" as mix; + +.half-width-datepicker { + .p-datepicker-panel { + min-width: mix.rem(350px) !important; + } +} diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss index 54217549e..84b2edaa3 100644 --- a/src/assets/styles/styles.scss +++ b/src/assets/styles/styles.scss @@ -43,3 +43,4 @@ @use "./components/md-editor"; @use "./components/preprints"; @use "./overrides/cedar-metadata"; +@use "./overrides/date-picker";