diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 4091fed03..20157b281 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -13,6 +13,7 @@ import { NotificationSubscriptionState } from '@osf/features/settings/notificati import { ProfileSettingsState } from '@osf/features/settings/profile-settings/store/profile-settings.state'; import { AddonsState, InstitutionsState } from '@shared/stores'; import { LicensesState } from '@shared/stores/licenses'; +import { RegionsState } from '@shared/stores/regions'; export const STATES = [ AuthState, @@ -31,4 +32,5 @@ export const STATES = [ RegistrationsState, ProjectMetadataState, LicensesState, + RegionsState, ]; diff --git a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.html b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.html new file mode 100644 index 000000000..ed5d97356 --- /dev/null +++ b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.html @@ -0,0 +1,17 @@ + +
+ + +
diff --git a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.scss b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.spec.ts b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.spec.ts new file mode 100644 index 000000000..0209f9b45 --- /dev/null +++ b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CreateProjectDialogComponent } from './create-project-dialog.component'; + +describe('CreateProjectDialogComponent', () => { + let component: CreateProjectDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CreateProjectDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CreateProjectDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts new file mode 100644 index 000000000..7e876ed95 --- /dev/null +++ b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.ts @@ -0,0 +1,93 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; + +import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants'; +import { CreateProject, GetMyProjects, MyProjectsSelectors } from '@osf/features/my-projects/store'; +import { AddProjectFormComponent } from '@shared/components'; +import { ProjectFormControls } from '@shared/enums'; +import { IdName, ProjectForm } from '@shared/models'; +import { CustomValidators } from '@shared/utils'; + +@Component({ + selector: 'osf-create-project-dialog', + imports: [AddProjectFormComponent, Button, TranslatePipe], + templateUrl: './create-project-dialog.component.html', + styleUrl: './create-project-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CreateProjectDialogComponent implements OnInit { + protected readonly dialogRef = inject(DynamicDialogRef); + + private actions = createDispatchMap({ + getMyProjects: GetMyProjects, + createProject: CreateProject, + }); + + private projects = select(MyProjectsSelectors.getProjects); + + readonly templates = computed(() => { + return this.projects().map( + (project) => + ({ + id: project.id, + name: project.title, + }) as IdName + ); + }); + readonly isProjectSubmitting = select(MyProjectsSelectors.isProjectSubmitting); + + readonly projectForm = new FormGroup({ + [ProjectFormControls.Title]: new FormControl('', { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed()], + }), + [ProjectFormControls.StorageLocation]: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + [ProjectFormControls.Affiliations]: new FormControl([], { + nonNullable: true, + }), + [ProjectFormControls.Description]: new FormControl('', { + nonNullable: true, + }), + [ProjectFormControls.Template]: new FormControl('', { + nonNullable: true, + }), + }); + + ngOnInit(): void { + this.actions.getMyProjects(1, MY_PROJECTS_TABLE_PARAMS.rows, {}); + } + + submitForm(): void { + if (this.projectForm.invalid) { + this.projectForm.markAllAsTouched(); + return; + } + + const formValue = this.projectForm.getRawValue(); + + this.actions + .createProject( + formValue.title, + formValue.description, + formValue.template, + formValue.storageLocation, + formValue.affiliations + ) + .subscribe({ + next: () => { + this.actions.getMyProjects(1, MY_PROJECTS_TABLE_PARAMS.rows, {}); + this.dialogRef.close(); + }, + }); + } +} diff --git a/src/app/features/my-projects/components/index.ts b/src/app/features/my-projects/components/index.ts new file mode 100644 index 000000000..e6df1d149 --- /dev/null +++ b/src/app/features/my-projects/components/index.ts @@ -0,0 +1 @@ +export { CreateProjectDialogComponent } from './create-project-dialog/create-project-dialog.component'; diff --git a/src/app/features/my-projects/models/create-project.model.ts b/src/app/features/my-projects/models/create-project.model.ts deleted file mode 100644 index 71dc48c8e..000000000 --- a/src/app/features/my-projects/models/create-project.model.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface CreateProjectPayloadJsoApi { - data: { - type: 'nodes'; - attributes: { - title: string; - description?: string; - category: 'project'; - template_from?: string; - }; - relationships: { - region: { - data: { - type: 'regions'; - id: string; - }; - }; - affiliated_institutions?: { - data: { - type: 'institutions'; - id: string; - }[]; - }; - }; - }; -} diff --git a/src/app/features/my-projects/models/index.ts b/src/app/features/my-projects/models/index.ts index dbb1e77dc..ed1688735 100644 --- a/src/app/features/my-projects/models/index.ts +++ b/src/app/features/my-projects/models/index.ts @@ -1,4 +1,3 @@ -export * from './create-project.model'; export * from './my-projects.models'; export * from './my-projects-endpoint.type'; export * from './my-projects-search-filters.models'; diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index a1651ecff..6830e85ac 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -26,12 +26,12 @@ import { ActivatedRoute, Router } from '@angular/router'; import { MY_PROJECTS_TABLE_PARAMS } from '@osf/core/constants'; import { parseQueryFilterParams } from '@osf/core/helpers'; -import { AddProjectFormComponent, MyProjectsTableComponent, SubHeaderComponent } from '@osf/shared/components'; +import { CreateProjectDialogComponent } from '@osf/features/my-projects/components'; +import { MyProjectsTableComponent, SubHeaderComponent } from '@osf/shared/components'; import { ResourceType, SortOrder } from '@osf/shared/enums'; import { QueryParams, TableParameters, TabOption } from '@osf/shared/models'; -import { IS_MEDIUM, IS_WEB, IS_XSMALL } from '@osf/shared/utils'; +import { IS_XSMALL } from '@osf/shared/utils'; -import { FetchUserInstitutions } from '../../shared/stores/institutions'; import { CollectionsSelectors, GetBookmarksCollectionId } from '../collections/store'; import { MyProjectsItem, MyProjectsSearchFilters } from './models'; @@ -73,8 +73,6 @@ export class MyProjectsComponent implements OnInit { protected readonly defaultTabValue = 0; protected readonly isLoading = signal(false); - protected readonly isDesktop = toSignal(inject(IS_WEB)); - protected readonly isTablet = toSignal(inject(IS_MEDIUM)); protected readonly isMobile = toSignal(inject(IS_XSMALL)); protected readonly tabOptions: TabOption[] = [ { @@ -128,7 +126,6 @@ export class MyProjectsComponent implements OnInit { } ngOnInit(): void { - this.#store.dispatch(new FetchUserInstitutions()); this.#store.dispatch(new GetBookmarksCollectionId()); } @@ -339,7 +336,7 @@ export class MyProjectsComponent implements OnInit { protected createProject(): void { const dialogWidth = this.isMobile() ? '95vw' : '850px'; - this.#dialogService.open(AddProjectFormComponent, { + this.#dialogService.open(CreateProjectDialogComponent, { width: dialogWidth, focusOnShow: false, header: this.#translateService.instant('myProjects.header.createProject'), diff --git a/src/app/features/my-projects/services/my-projects.service.ts b/src/app/features/my-projects/services/my-projects.service.ts index c05e86d32..9effb9750 100644 --- a/src/app/features/my-projects/services/my-projects.service.ts +++ b/src/app/features/my-projects/services/my-projects.service.ts @@ -7,11 +7,10 @@ import { JsonApiResponse } from '@core/models'; import { JsonApiService } from '@osf/core/services'; import { SparseCollectionsResponseJsonApi } from '@osf/features/collections/models'; import { ResourceType, SortOrder } from '@osf/shared/enums'; -import { NodeResponseModel, UpdateNodeRequestModel } from '@shared/models'; +import { CreateProjectPayloadJsoApi, NodeResponseModel, UpdateNodeRequestModel } from '@shared/models'; import { MyProjectsMapper } from '../mappers'; import { - CreateProjectPayloadJsoApi, EndpointType, MyProjectsItem, MyProjectsItemGetResponseJsonApi, diff --git a/src/app/features/my-projects/store/my-projects.selectors.ts b/src/app/features/my-projects/store/my-projects.selectors.ts index 58cb67113..8a85cff91 100644 --- a/src/app/features/my-projects/store/my-projects.selectors.ts +++ b/src/app/features/my-projects/store/my-projects.selectors.ts @@ -11,6 +11,11 @@ export class MyProjectsSelectors { return state.projects.data; } + @Selector([MyProjectsState]) + static isProjectSubmitting(state: MyProjectsStateModel): boolean { + return state.projects.isSubmitting || false; + } + @Selector([MyProjectsState]) static getRegistrations(state: MyProjectsStateModel): MyProjectsItem[] { return state.registrations.data; diff --git a/src/app/features/my-projects/store/my-projects.state.ts b/src/app/features/my-projects/store/my-projects.state.ts index c45742f69..a0e1cc27a 100644 --- a/src/app/features/my-projects/store/my-projects.state.ts +++ b/src/app/features/my-projects/store/my-projects.state.ts @@ -234,7 +234,7 @@ export class MyProjectsState { ctx.patchState({ projects: { ...state.projects, - isLoading: true, + isSubmitting: true, }, }); @@ -246,6 +246,7 @@ export class MyProjectsState { projects: { data: [project, ...state.projects.data], isLoading: false, + isSubmitting: false, error: null, }, totalProjects: state.totalProjects + 1, diff --git a/src/app/features/preprints/components/index.ts b/src/app/features/preprints/components/index.ts index 11c97beec..a5ba927a9 100644 --- a/src/app/features/preprints/components/index.ts +++ b/src/app/features/preprints/components/index.ts @@ -8,6 +8,8 @@ export { PreprintProviderFooterComponent } from './preprint-provider-footer/prep export { PreprintProviderHeroComponent } from './preprint-provider-hero/preprint-provider-hero.component'; export { PreprintServicesComponent } from './preprint-services/preprint-services.component'; export { PreprintsHelpDialogComponent } from './preprints-help-dialog/preprints-help-dialog.component'; +export { AuthorAssertionsStepComponent } from './stepper/author-assertion-step/author-assertions-step.component'; +export { SupplementsStepComponent } from './stepper/supplements-step/supplements-step.component'; export { PreprintsFilterChipsComponent } from '@osf/features/preprints/components/filters/preprints-filter-chips/preprints-filter-chips.component'; export { PreprintsResourcesComponent } from '@osf/features/preprints/components/filters/preprints-resources/preprints-resources.component'; export { PreprintsResourcesFiltersComponent } from '@osf/features/preprints/components/filters/preprints-resources-filters/preprints-resources-filters.component'; diff --git a/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.html b/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.html index a2bbde693..cdb5f99ac 100644 --- a/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.html +++ b/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.html @@ -41,6 +41,10 @@

Publication DOI

+
+ +
+

Tags (optional)

diff --git a/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.ts b/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.ts index 2d3cfd76e..d9266fb8b 100644 --- a/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.ts +++ b/src/app/features/preprints/components/stepper/metadata-step/metadata-step.component.ts @@ -12,6 +12,7 @@ import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, HostListener, OnInit, output } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { PreprintsSubjectsComponent } from '@osf/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component'; import { formInputLimits } from '@osf/features/preprints/constants'; import { MetadataForm, Preprint } from '@osf/features/preprints/models'; import { @@ -44,6 +45,7 @@ import { ContributorsComponent } from './contributors/contributors.component'; Tooltip, LicenseComponent, TagsInputComponent, + PreprintsSubjectsComponent, ], templateUrl: './metadata-step.component.html', styleUrl: './metadata-step.component.scss', diff --git a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.html b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.html new file mode 100644 index 000000000..4b926a1c0 --- /dev/null +++ b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.html @@ -0,0 +1,7 @@ + diff --git a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.scss b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.spec.ts b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.spec.ts new file mode 100644 index 000000000..dfc8c1641 --- /dev/null +++ b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PreprintsSubjectsComponent } from './preprints-subjects.component'; + +describe('RegistriesSubjectsComponent', () => { + let component: PreprintsSubjectsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PreprintsSubjectsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PreprintsSubjectsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts new file mode 100644 index 000000000..23a7556d2 --- /dev/null +++ b/src/app/features/preprints/components/stepper/metadata-step/preprints-subjects/preprints-subjects.component.ts @@ -0,0 +1,49 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; + +import { + FetchPreprintsSubjects, + SubmitPreprintSelectors, + UpdatePreprintsSubjects, +} from '@osf/features/preprints/store/submit-preprint'; +import { SubjectsComponent } from '@osf/shared/components'; +import { Subject } from '@osf/shared/models'; +import { FetchChildrenSubjects, FetchSubjects } from '@osf/shared/stores'; + +@Component({ + selector: 'osf-preprints-subjects', + imports: [SubjectsComponent], + templateUrl: './preprints-subjects.component.html', + styleUrl: './preprints-subjects.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PreprintsSubjectsComponent implements OnInit { + private readonly selectedProviderId = select(SubmitPreprintSelectors.getSelectedProviderId); + protected selectedSubjects = select(SubmitPreprintSelectors.getSelectedSubjects); + protected isSubjectsUpdating = select(SubmitPreprintSelectors.isSubjectsUpdating); + + protected actions = createDispatchMap({ + fetchSubjects: FetchSubjects, + fetchPreprintsSubjects: FetchPreprintsSubjects, + fetchChildrenSubjects: FetchChildrenSubjects, + updatePreprintsSubjects: UpdatePreprintsSubjects, + }); + + ngOnInit(): void { + this.actions.fetchSubjects(this.selectedProviderId()!); + this.actions.fetchPreprintsSubjects(); + } + + getSubjectChildren(parentId: string) { + this.actions.fetchChildrenSubjects(parentId); + } + + searchSubjects(search: string) { + this.actions.fetchSubjects(search); + } + + updateSelectedSubjects(subjects: Subject[]) { + this.actions.updatePreprintsSubjects(subjects); + } +} diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html new file mode 100644 index 000000000..b942bf210 --- /dev/null +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html @@ -0,0 +1,84 @@ +

Supplements (optional)

+ +
+

Connect an OSF project to share data, code, protocols, or other supplemental materials.

+
+ +@let createdPreprintNodeId = createdPreprint()?.nodeId; +@if (!createdPreprintNodeId) { +
+ + +
+ + @if (selectedSupplementOption() === SupplementOptions.ConnectExistingProject) { + +
+

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

+

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

+ + +
+
+ } @else if (selectedSupplementOption() === SupplementOptions.CreateNewProject) { + + + + } +} @else { +
+ @if (isPreprintProjectLoading()) { + + } @else { +
+

{{ preprintProject()?.name }}

+ + +
+ } +
+} + +
+ + +
diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.scss b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.scss new file mode 100644 index 000000000..16b791219 --- /dev/null +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.scss @@ -0,0 +1,21 @@ +@use "assets/styles/mixins" as mix; + +.supplement-option-button { + --p-button-secondary-border-color: var(--grey-2); + --p-button-secondary-background: transparent; + --p-button-secondary-hover-background: var(--bg-blue-3); + --p-button-padding-y: 0.75rem; + --p-button-secondary-color: var(--dark-blue-1); + --p-button-secondary-hover-color: var(--dark-blue-1); + + &.active { + --p-button-secondary-background: var(--bg-blue-3); + } +} + +.selected-project { + @include mix.flex-center-between; + padding: mix.rem(6px) mix.rem(12px); + border-bottom: 1px solid var(--grey-2); + border-top: 1px solid var(--grey-2); +} diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts new file mode 100644 index 000000000..2cc79b093 --- /dev/null +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SupplementsStepComponent } from './supplements-step.component'; + +describe('SupplementsStepComponent', () => { + let component: SupplementsStepComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SupplementsStepComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SupplementsStepComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts new file mode 100644 index 000000000..4d820d84a --- /dev/null +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts @@ -0,0 +1,224 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Skeleton } from 'primeng/skeleton'; + +import { debounceTime, distinctUntilChanged, map } from 'rxjs'; + +import { NgClass, TitleCasePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostListener, + inject, + OnInit, + output, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { StringOrNull } from '@core/helpers'; +import { SupplementOptions } from '@osf/features/preprints/enums'; +import { + ConnectProject, + CreateNewProject, + DisconnectProject, + FetchPreprintProject, + GetAvailableProjects, + SubmitPreprintSelectors, +} from '@osf/features/preprints/store/submit-preprint'; +import { AddProjectFormComponent } from '@shared/components'; +import { ProjectFormControls } from '@shared/enums'; +import { ProjectForm } from '@shared/models'; +import { CustomConfirmationService, ToastService } from '@shared/services'; +import { CustomValidators } from '@shared/utils'; + +@Component({ + selector: 'osf-supplements-step', + imports: [Button, TitleCasePipe, NgClass, Card, Select, AddProjectFormComponent, ReactiveFormsModule, Skeleton], + templateUrl: './supplements-step.component.html', + styleUrl: './supplements-step.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SupplementsStepComponent implements OnInit { + private customConfirmationService = inject(CustomConfirmationService); + private readonly toastService = inject(ToastService); + private actions = createDispatchMap({ + getAvailableProjects: GetAvailableProjects, + connectProject: ConnectProject, + disconnectProject: DisconnectProject, + fetchPreprintProject: FetchPreprintProject, + createNewProject: CreateNewProject, + }); + private destroyRef = inject(DestroyRef); + + readonly SupplementOptions = SupplementOptions; + + createdPreprint = select(SubmitPreprintSelectors.getCreatedPreprint); + isPreprintSubmitting = select(SubmitPreprintSelectors.isPreprintSubmitting); + availableProjects = select(SubmitPreprintSelectors.getAvailableProjects); + areAvailableProjectsLoading = select(SubmitPreprintSelectors.areAvailableProjectsLoading); + preprintProject = select(SubmitPreprintSelectors.getPreprintProject); + isPreprintProjectLoading = select(SubmitPreprintSelectors.isPreprintProjectLoading); + + selectedSupplementOption = signal(SupplementOptions.None); + selectedProjectId = signal(null); + + readonly projectNameControl = new FormControl(null); + readonly createProjectForm = new FormGroup({ + [ProjectFormControls.Title]: new FormControl('', { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed()], + }), + [ProjectFormControls.StorageLocation]: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + [ProjectFormControls.Affiliations]: new FormControl([], { + nonNullable: true, + }), + [ProjectFormControls.Description]: new FormControl('', { + nonNullable: true, + }), + [ProjectFormControls.Template]: new FormControl('', { + nonNullable: true, + }), + }); + + createProjectFormValid = toSignal(this.createProjectForm.statusChanges.pipe(map((status) => status === 'VALID')), { + initialValue: this.createProjectForm.valid, + }); + + isNextButtonDisabled = computed(() => { + if (this.createdPreprint()?.nodeId) { + return false; + } + + switch (this.selectedSupplementOption()) { + case SupplementOptions.None: + return true; + case SupplementOptions.ConnectExistingProject: + return !this.createdPreprint()?.nodeId; + case SupplementOptions.CreateNewProject: + return !this.createProjectFormValid(); + default: + return false; + } + }); + + constructor() { + effect(() => { + const preprint = this.createdPreprint(); + if (!preprint?.nodeId) { + return; + } + + untracked(() => { + const preprintProject = this.preprintProject(); + if (preprint.nodeId === preprintProject?.id) { + return; + } + }); + + this.actions.fetchPreprintProject(); + }); + } + + nextClicked = output(); + + ngOnInit() { + this.projectNameControl.valueChanges + .pipe(debounceTime(500), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((projectNameOrId) => { + if (this.selectedProjectId() === projectNameOrId) { + return; + } + + this.actions.getAvailableProjects(projectNameOrId); + }); + } + + selectSupplementOption(supplementOption: SupplementOptions) { + this.selectedSupplementOption.set(supplementOption); + + if (supplementOption === SupplementOptions.CreateNewProject) { + this.createProjectForm.reset(); + } + + this.actions.getAvailableProjects(null); + } + + selectProject(event: SelectChangeEvent) { + if (!(event.originalEvent instanceof PointerEvent)) { + return; + } + this.selectedProjectId.set(event.value); + + this.actions.connectProject(event.value).subscribe({ + complete: () => { + this.toastService.showSuccess('Project connected successfully'); + }, + }); + } + + disconnectProject() { + this.customConfirmationService.confirmDelete({ + headerKey: 'Disconnect supplemental material', + messageKey: + 'This will disconnect the selected project. You can select new supplemental material or re-add the same supplemental material at a later date.', + onConfirm: () => { + this.actions.disconnectProject().subscribe({ + complete: () => { + this.selectedProjectId.set(null); + this.toastService.showSuccess('Project disconnected successfully'); + }, + }); + }, + }); + } + + submitCreateProjectForm() { + if (this.createProjectForm.invalid) { + return; + } + + const rawData = this.createProjectForm.getRawValue(); + + this.actions + .createNewProject( + rawData.title, + rawData.description, + rawData.template, + rawData.storageLocation, + rawData.affiliations + ) + .subscribe({ + complete: () => { + this.toastService.showSuccess('Project created successfully'); + this.nextClicked.emit(); + }, + }); + } + + nextButtonClicked() { + if (this.selectedSupplementOption() === SupplementOptions.CreateNewProject) { + this.submitCreateProjectForm(); + return; + } + + this.nextClicked.emit(); + } + + @HostListener('window:beforeunload', ['$event']) + public onBeforeUnload($event: BeforeUnloadEvent): boolean { + $event.preventDefault(); + return false; + } +} diff --git a/src/app/features/preprints/enums/index.ts b/src/app/features/preprints/enums/index.ts index bf1bf51c4..6e7a24296 100644 --- a/src/app/features/preprints/enums/index.ts +++ b/src/app/features/preprints/enums/index.ts @@ -2,3 +2,4 @@ export { ApplicabilityStatus } from './applicability-status.enum'; export { PreprintFileSource } from './preprint-file-source.enum'; export { PreregLinkInfo } from './prereg-link-info.enum'; export { SubmitSteps } from './submit-steps.enum'; +export { SupplementOptions } from './supplement-options.enum'; diff --git a/src/app/features/preprints/enums/supplement-options.enum.ts b/src/app/features/preprints/enums/supplement-options.enum.ts new file mode 100644 index 000000000..0b3e50a73 --- /dev/null +++ b/src/app/features/preprints/enums/supplement-options.enum.ts @@ -0,0 +1,5 @@ +export enum SupplementOptions { + None, + ConnectExistingProject, + CreateNewProject, +} diff --git a/src/app/features/preprints/mappers/preprints.mapper.ts b/src/app/features/preprints/mappers/preprints.mapper.ts index 2661e1388..5add26b6d 100644 --- a/src/app/features/preprints/mappers/preprints.mapper.ts +++ b/src/app/features/preprints/mappers/preprints.mapper.ts @@ -37,7 +37,8 @@ export class PreprintsMapper { isPublic: response.attributes.public, version: response.attributes.version, isLatestVersion: response.attributes.is_latest_version, - primaryFileId: response.relationships.primary_file?.links?.related?.href || null, + primaryFileId: response.relationships.primary_file?.data?.id || null, + nodeId: response.relationships.node?.data?.id, licenseId: response.relationships.license?.data?.id || null, licenseOptions: response.attributes.license_record ? { diff --git a/src/app/features/preprints/models/preprint-json-api.models.ts b/src/app/features/preprints/models/preprint-json-api.models.ts index 44ae1cd64..f09523304 100644 --- a/src/app/features/preprints/models/preprint-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-json-api.models.ts @@ -36,10 +36,9 @@ export interface PreprintJsonApi { export interface PreprintsRelationshipsJsonApi { primary_file: { - links: { - related: { - href: string; - }; + data: { + id: string; + type: 'files'; }; }; license: { @@ -48,4 +47,10 @@ export interface PreprintsRelationshipsJsonApi { type: 'licenses'; }; }; + node: { + data: { + id: string; + type: 'nodes'; + }; + }; } diff --git a/src/app/features/preprints/models/preprint.models.ts b/src/app/features/preprints/models/preprint.models.ts index 7f797cd77..d791c2d23 100644 --- a/src/app/features/preprints/models/preprint.models.ts +++ b/src/app/features/preprints/models/preprint.models.ts @@ -16,6 +16,7 @@ export interface Preprint { isPublic: boolean; version: number; isLatestVersion: boolean; + nodeId: StringOrNull; primaryFileId: StringOrNull; licenseId: StringOrNull; licenseOptions: LicenseOptions | null; 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 5a0babd6c..10e735f39 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 @@ -45,6 +45,9 @@

{{ 'Add a ' + preprintProvider()!.preprintWor @case (SubmitStepsEnum.AuthorAssertions) { } + @case (SubmitStepsEnum.Supplements) { + + } @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 0aee68d95..fcb81928b 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 @@ -18,11 +18,12 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { + AuthorAssertionsStepComponent, FileStepComponent, MetadataStepComponent, + SupplementsStepComponent, TitleAndAbstractStepComponent, } from '@osf/features/preprints/components'; -import { AuthorAssertionsStepComponent } from '@osf/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component'; import { submitPreprintSteps } from '@osf/features/preprints/constants'; import { SubmitSteps } from '@osf/features/preprints/enums'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '@osf/features/preprints/store/preprint-providers'; @@ -44,6 +45,8 @@ import { BrowserTabHelper, HeaderStyleHelper, IS_WEB } from '@shared/utils'; FileStepComponent, MetadataStepComponent, AuthorAssertionsStepComponent, + SupplementsStepComponent, + AuthorAssertionsStepComponent, ], templateUrl: './submit-preprint-stepper.component.html', styleUrl: './submit-preprint-stepper.component.scss', diff --git a/src/app/features/preprints/preprints.routes.ts b/src/app/features/preprints/preprints.routes.ts index 3bb1126f2..f2caae2b0 100644 --- a/src/app/features/preprints/preprints.routes.ts +++ b/src/app/features/preprints/preprints.routes.ts @@ -3,12 +3,14 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { PreprintsComponent } from '@osf/features/preprints/preprints.component'; +import { PreprintSubjectsService } from '@osf/features/preprints/services'; import { PreprintProvidersState } from '@osf/features/preprints/store/preprint-providers'; import { PreprintsDiscoverState } from '@osf/features/preprints/store/preprints-discover'; import { PreprintsResourcesFiltersState } from '@osf/features/preprints/store/preprints-resources-filters'; import { PreprintsResourcesFiltersOptionsState } from '@osf/features/preprints/store/preprints-resources-filters-options'; import { SubmitPreprintState } from '@osf/features/preprints/store/submit-preprint'; -import { ContributorsState } from '@osf/shared/stores'; +import { ContributorsState, SubjectsState } from '@shared/stores'; +import { SUBJECTS_SERVICE } from '@shared/tokens/subjects.token'; export const preprintsRoutes: Routes = [ { @@ -22,7 +24,12 @@ export const preprintsRoutes: Routes = [ PreprintsResourcesFiltersOptionsState, SubmitPreprintState, ContributorsState, + SubjectsState, ]), + { + provide: SUBJECTS_SERVICE, + useClass: PreprintSubjectsService, + }, ], children: [ { diff --git a/src/app/features/preprints/services/index.ts b/src/app/features/preprints/services/index.ts index 04a25545e..8d103600e 100644 --- a/src/app/features/preprints/services/index.ts +++ b/src/app/features/preprints/services/index.ts @@ -1,5 +1,7 @@ export { PreprintFilesService } from './preprint-files.service'; export { PreprintLicensesService } from './preprint-licenses.service'; export { PreprintProvidersService } from './preprint-providers.service'; +export { PreprintSubjectsService } from './preprint-subjects.service'; export { PreprintsService } from './preprints.service'; +export { PreprintsProjectsService } from './preprints-projects.service'; export { PreprintsFiltersOptionsService } from './preprints-resource-filters.service'; diff --git a/src/app/features/preprints/services/preprint-files.service.ts b/src/app/features/preprints/services/preprint-files.service.ts index 141d255b8..5a8effc95 100644 --- a/src/app/features/preprints/services/preprint-files.service.ts +++ b/src/app/features/preprints/services/preprint-files.service.ts @@ -2,7 +2,6 @@ import { map, Observable, switchMap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { Primitive, StringOrNull } from '@core/helpers'; import { JsonApiService } from '@core/services'; import { ApiData, JsonApiResponse } from '@osf/core/models'; import { PreprintsMapper } from '@osf/features/preprints/mappers'; @@ -12,7 +11,7 @@ import { PreprintJsonApi, PreprintsRelationshipsJsonApi, } from '@osf/features/preprints/models'; -import { GetFileResponse, GetFilesResponse, IdName, NodeData, OsfFile } from '@osf/shared/models'; +import { GetFileResponse, GetFilesResponse, OsfFile } from '@osf/shared/models'; import { FilesService } from '@shared/services'; import { environment } from 'src/environments/environment'; @@ -61,25 +60,6 @@ export class PreprintFilesService { ); } - getAvailableProjects(searchTerm: StringOrNull): Observable { - const params: Record = {}; - params['page'] = 1; - if (searchTerm) { - params['filter[title]'] = searchTerm; - } - - return this.jsonApiService - .get>(`${environment.apiUrl}/users/me/nodes/`, params) - .pipe( - map((response) => { - return response.data.map((item) => ({ - id: item.id, - name: item.attributes.title, - })); - }) - ); - } - getProjectFiles(projectId: string): Observable { return this.jsonApiService.get(`${environment.apiUrl}/nodes/${projectId}/files/`).pipe( switchMap((response: GetFilesResponse) => { diff --git a/src/app/features/preprints/services/preprint-subjects.service.ts b/src/app/features/preprints/services/preprint-subjects.service.ts new file mode 100644 index 000000000..d41c3421a --- /dev/null +++ b/src/app/features/preprints/services/preprint-subjects.service.ts @@ -0,0 +1,78 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@osf/core/services'; +import { SubjectMapper } from '@osf/shared/mappers'; +import { ISubjectsService, Subject, SubjectsResponseJsonApi } from '@osf/shared/models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class PreprintSubjectsService implements ISubjectsService { + private apiUrl = environment.apiUrl; + private readonly jsonApiService = inject(JsonApiService); + + getSubjects(providerId: string, search?: string): Observable { + const params: Record = { + 'page[size]': '100', + sort: 'text', + related_counts: 'children', + 'filter[parent]': 'null', + }; + if (search) { + delete params['filter[parent]']; + params['filter[text]'] = search; + params['embed'] = 'parent'; + } + return this.jsonApiService + .get(`${this.apiUrl}/providers/preprints/${providerId}/subjects/`, params) + .pipe( + map((response) => { + return SubjectMapper.fromSubjectsResponseJsonApi(response); + }) + ); + } + + getChildrenSubjects(parentId: string): Observable { + const params: Record = { + 'page[size]': '100', + page: '1', + sort: 'text', + related_counts: 'children', + }; + + return this.jsonApiService + .get(`${this.apiUrl}/subjects/${parentId}/children/`, params) + .pipe( + map((response) => { + return SubjectMapper.fromSubjectsResponseJsonApi(response); + }) + ); + } + + getPreprintSubjects(preprintId: string): Observable { + const params: Record = { + 'page[size]': '100', + page: '1', + }; + + return this.jsonApiService + .get(`${this.apiUrl}/preprints/${preprintId}/subjects/`, params) + .pipe( + map((response) => { + return SubjectMapper.fromSubjectsResponseJsonApi(response); + }) + ); + } + + updatePreprintSubjects(preprintId: string, subjects: Subject[]): Observable { + const payload = { + data: subjects.map((item) => ({ id: item.id, type: 'subjects' })), + }; + + return this.jsonApiService.put(`${this.apiUrl}/preprints/${preprintId}/relationships/subjects/`, payload); + } +} diff --git a/src/app/features/preprints/services/preprints-projects.service.ts b/src/app/features/preprints/services/preprints-projects.service.ts new file mode 100644 index 000000000..4c47f580f --- /dev/null +++ b/src/app/features/preprints/services/preprints-projects.service.ts @@ -0,0 +1,123 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { Primitive, StringOrNull } from '@core/helpers'; +import { JsonApiService } from '@core/services'; +import { ApiData, JsonApiResponse } from '@osf/core/models'; +import { PreprintsMapper } from '@osf/features/preprints/mappers'; +import { Preprint, PreprintJsonApi, PreprintsRelationshipsJsonApi } from '@osf/features/preprints/models'; +import { CreateProjectPayloadJsoApi, IdName, NodeData } from '@osf/shared/models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class PreprintsProjectsService { + private jsonApiService = inject(JsonApiService); + + getAvailableProjects(searchTerm: StringOrNull): Observable { + const params: Record = {}; + params['page'] = 1; + if (searchTerm) { + params['filter[title]'] = searchTerm; + } + + return this.jsonApiService + .get>(`${environment.apiUrl}/users/me/nodes/`, params) + .pipe( + map((response) => { + return response.data.map((item) => ({ + id: item.id, + name: item.attributes.title, + })); + }) + ); + } + + getProjectById(projectId: string): Observable { + return this.jsonApiService.get>(`${environment.apiUrl}/nodes/${projectId}/`).pipe( + map((response) => { + return { + id: response.data.id, + name: response.data.attributes.title, + }; + }) + ); + } + + removePreprintProjectRelationship(preprintId: string) { + return this.jsonApiService.patch(`${environment.apiUrl}/preprints/${preprintId}/relationships/node/`, { + data: [], + }); + } + + updatePreprintProjectRelationship(preprintId: string, projectId: string): Observable { + return this.jsonApiService + .patch>( + `${environment.apiUrl}/preprints/${preprintId}/`, + { + data: { + type: 'preprints', + id: preprintId, + attributes: {}, + relationships: { + node: { + data: { + type: 'nodes', + id: projectId, + }, + }, + }, + }, + } + ) + .pipe(map((response) => PreprintsMapper.fromPreprintJsonApi(response))); + } + + createProject( + title: string, + description: string, + templateFrom: string, + regionId: string, + affiliationsId: string[] + ): Observable { + const payload: CreateProjectPayloadJsoApi = { + data: { + type: 'nodes', + attributes: { + title, + ...(description && { description }), + category: 'project', + ...(templateFrom && { template_from: templateFrom }), + }, + relationships: { + region: { + data: { + type: 'regions', + id: regionId, + }, + }, + ...(affiliationsId.length > 0 && { + affiliated_institutions: { + data: affiliationsId.map((id) => ({ + type: 'institutions', + id, + })), + }, + }), + }, + }, + }; + + return this.jsonApiService.post>(`${environment.apiUrl}/nodes/`, payload).pipe( + map((response) => { + return { + id: response.data.id, + name: response.data.attributes.title, + }; + }) + ); + } +} diff --git a/src/app/features/preprints/store/preprints-discover/preprints-discover.state.ts b/src/app/features/preprints/store/preprints-discover/preprints-discover.state.ts index 65167f668..b2731569b 100644 --- a/src/app/features/preprints/store/preprints-discover/preprints-discover.state.ts +++ b/src/app/features/preprints/store/preprints-discover/preprints-discover.state.ts @@ -13,7 +13,6 @@ import { SetSortBy, } from '@osf/features/preprints/store/preprints-discover/preprints-discover.actions'; import { PreprintsDiscoverStateModel } from '@osf/features/preprints/store/preprints-discover/preprints-discover.model'; -import { PreprintsDiscoverSelectors } from '@osf/features/preprints/store/preprints-discover/preprints-discover.selectors'; import { PreprintsResourcesFiltersSelectors } from '@osf/features/preprints/store/preprints-resources-filters'; import { ResourceFiltersStateModel } from '@osf/features/search/components/resource-filters/store'; import { GetResourcesRequestTypeEnum, ResourceTab } from '@shared/enums'; @@ -53,13 +52,11 @@ export class PreprintsDiscoverState implements NgxsOnInit { if (query.type === GetResourcesRequestTypeEnum.GetResources) { const filters = this.store.selectSnapshot(PreprintsResourcesFiltersSelectors.getAllFilters); const filtersParams = addFiltersParams(filters as ResourceFiltersStateModel); - const searchText = this.store.selectSnapshot(PreprintsDiscoverSelectors.getSearchText); - const sortBy = this.store.selectSnapshot(PreprintsDiscoverSelectors.getSortBy); + const searchText = state.searchText; + const sortBy = state.sortBy; const resourceTab = ResourceTab.Preprints; const resourceTypes = getResourceTypes(resourceTab); - filtersParams['cardSearchFilter[publisher][]'] = this.store.selectSnapshot( - PreprintsDiscoverSelectors.getIri - ); + filtersParams['cardSearchFilter[publisher][]'] = state.providerIri; return this.searchService.getResources(filtersParams, searchText, sortBy, resourceTypes).pipe( tap((response) => { @@ -97,8 +94,8 @@ export class PreprintsDiscoverState implements NgxsOnInit { } @Action(GetResources) - getResources() { - if (!this.store.selectSnapshot(PreprintsDiscoverSelectors.getIri)) { + getResources(ctx: StateContext) { + if (!ctx.getState().providerIri) { return; } this.loadRequests.next({ 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 51b493804..f379ce1bb 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,7 +1,7 @@ import { StringOrNull } from '@core/helpers'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { Preprint } from '@osf/features/preprints/models'; -import { LicenseOptions, OsfFile } from '@shared/models'; +import { LicenseOptions, OsfFile, Subject } from '@shared/models'; export class SetSelectedPreprintProviderId { static readonly type = '[Submit Preprint] Set Selected Preprint Provider Id'; @@ -91,6 +91,42 @@ export class SaveLicense { ) {} } +export class FetchPreprintsSubjects { + static readonly type = '[Submit Preprint] Fetch Registration Subjects'; +} + +export class UpdatePreprintsSubjects { + static readonly type = '[Submit Preprint] Update Registration Subject'; + + constructor(public subjects: Subject[]) {} +} + +export class DisconnectProject { + static readonly type = '[Submit Preprint] Disconnect Preprint Project'; +} + +export class ConnectProject { + static readonly type = '[Submit Preprint] Connect Preprint Project'; + + constructor(public projectId: string) {} +} + +export class FetchPreprintProject { + static readonly type = '[Submit Preprint] Fetch Preprint Project'; +} + +export class CreateNewProject { + static readonly type = '[Submit Preprint] Create Project'; + + constructor( + public title: string, + public description: string, + public templateFrom: string, + public regionId: string, + public affiliationsId: string[] + ) {} +} + 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 8ca3ff6cf..8d43c442c 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,7 @@ import { StringOrNull } from '@core/helpers'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { Preprint, PreprintFilesLinks } from '@osf/features/preprints/models'; -import { AsyncStateModel, IdName, OsfFile } from '@shared/models'; +import { AsyncStateModel, IdName, OsfFile, Subject } from '@shared/models'; import { License } from '@shared/models/license.model'; export interface SubmitPreprintStateModel { @@ -13,4 +13,6 @@ export interface SubmitPreprintStateModel { availableProjects: AsyncStateModel; projectFiles: AsyncStateModel; licenses: AsyncStateModel; + subjects: AsyncStateModel; + preprintProject: 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 4f434547c..f26023140 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 @@ -62,4 +62,24 @@ export class SubmitPreprintSelectors { static getLicenses(state: SubmitPreprintStateModel) { return state.licenses.data; } + + @Selector([SubmitPreprintState]) + static getSelectedSubjects(state: SubmitPreprintStateModel) { + return state.subjects.data; + } + + @Selector([SubmitPreprintState]) + static isSubjectsUpdating(state: SubmitPreprintStateModel) { + return state.subjects.isLoading; + } + + @Selector([SubmitPreprintState]) + static getPreprintProject(state: SubmitPreprintStateModel) { + return state.preprintProject.data; + } + + @Selector([SubmitPreprintState]) + static isPreprintProjectLoading(state: SubmitPreprintStateModel) { + return state.preprintProject.isLoading; + } } 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 ac79fcf81..1cfd51b7c 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,22 +1,34 @@ import { Action, State, StateContext } from '@ngxs/store'; import { patch } from '@ngxs/store/operators'; -import { EMPTY, filter, switchMap, tap, throwError } from 'rxjs'; +import { EMPTY, filter, forkJoin, of, switchMap, tap, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { HttpEventType } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; +import { handleSectionError } from '@core/handlers'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { Preprint } from '@osf/features/preprints/models'; -import { PreprintFilesService, PreprintLicensesService, PreprintsService } from '@osf/features/preprints/services'; +import { + PreprintFilesService, + PreprintLicensesService, + PreprintsProjectsService, + PreprintsService, + PreprintSubjectsService, +} from '@osf/features/preprints/services'; import { OsfFile } from '@shared/models'; import { FilesService } from '@shared/services'; import { + ConnectProject, CopyFileFromProject, + CreateNewProject, CreatePreprint, + DisconnectProject, FetchLicenses, + FetchPreprintProject, + FetchPreprintsSubjects, GetAvailableProjects, GetPreprintFiles, GetPreprintFilesLinks, @@ -29,46 +41,59 @@ import { SetSelectedPreprintProviderId, SubmitPreprintStateModel, UpdatePreprint, + UpdatePreprintsSubjects, UploadFile, } from './'; +const DefaultState: SubmitPreprintStateModel = { + selectedProviderId: null, + createdPreprint: { + data: null, + isLoading: false, + error: null, + isSubmitting: false, + }, + fileSource: PreprintFileSource.None, + preprintFilesLinks: { + data: null, + isLoading: false, + error: null, + }, + preprintFiles: { + data: [], + isLoading: false, + error: null, + }, + availableProjects: { + data: [], + isLoading: false, + error: null, + }, + projectFiles: { + data: [], + isLoading: false, + error: null, + }, + licenses: { + data: [], + isLoading: false, + error: null, + }, + subjects: { + data: [], + isLoading: false, + error: null, + }, + preprintProject: { + data: null, + isLoading: false, + error: null, + }, +}; + @State({ name: 'submitPreprint', - defaults: { - selectedProviderId: null, - createdPreprint: { - data: null, - isLoading: false, - error: null, - isSubmitting: false, - }, - fileSource: PreprintFileSource.None, - preprintFilesLinks: { - data: null, - isLoading: false, - error: null, - }, - preprintFiles: { - data: [], - isLoading: false, - error: null, - }, - availableProjects: { - data: [], - isLoading: false, - error: null, - }, - projectFiles: { - data: [], - isLoading: false, - error: null, - }, - licenses: { - data: [], - isLoading: false, - error: null, - }, - }, + defaults: { ...DefaultState }, }) @Injectable() export class SubmitPreprintState { @@ -76,6 +101,8 @@ export class SubmitPreprintState { private preprintFilesService = inject(PreprintFilesService); private fileService = inject(FilesService); private licensesService = inject(PreprintLicensesService); + private subjectsService = inject(PreprintSubjectsService); + private preprintProjectsService = inject(PreprintsProjectsService); @Action(SetSelectedPreprintProviderId) setSelectedPreprintProviderId(ctx: StateContext, action: SetSelectedPreprintProviderId) { @@ -200,7 +227,7 @@ export class SubmitPreprintState { getAvailableProjects(ctx: StateContext, action: GetAvailableProjects) { ctx.setState(patch({ availableProjects: patch({ isLoading: true }) })); - return this.preprintFilesService.getAvailableProjects(action.searchTerm).pipe( + return this.preprintProjectsService.getAvailableProjects(action.searchTerm).pipe( tap((projects) => { ctx.setState( patch({ @@ -266,41 +293,7 @@ export class SubmitPreprintState { resetStateAndDeletePreprint(ctx: StateContext) { const state = ctx.getState(); const createdPreprintId = state.createdPreprint.data?.id; - ctx.setState({ - selectedProviderId: null, - createdPreprint: { - data: null, - isLoading: false, - error: null, - isSubmitting: false, - }, - fileSource: PreprintFileSource.None, - preprintFilesLinks: { - data: null, - isLoading: false, - error: null, - }, - preprintFiles: { - data: [], - isLoading: false, - error: null, - }, - availableProjects: { - data: [], - isLoading: false, - error: null, - }, - projectFiles: { - data: [], - isLoading: false, - error: null, - }, - licenses: { - data: [], - isLoading: false, - error: null, - }, - }); + ctx.setState({ ...DefaultState }); if (createdPreprintId) { return this.preprintsService.deletePreprint(createdPreprintId); } @@ -377,6 +370,156 @@ export class SubmitPreprintState { ); } + @Action(FetchPreprintsSubjects) + fetchPreprintsSubjects(ctx: StateContext) { + const createdPreprintId = ctx.getState().createdPreprint.data!.id; + if (!createdPreprintId) return EMPTY; + + ctx.setState(patch({ subjects: patch({ isLoading: true }) })); + + return this.subjectsService.getPreprintSubjects(createdPreprintId).pipe( + tap((subjects) => { + ctx.patchState({ + subjects: { + data: subjects, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'subjects', error)) + ); + } + + @Action(UpdatePreprintsSubjects) + updatePreprintsSubjects(ctx: StateContext, { subjects }: UpdatePreprintsSubjects) { + const createdPreprintId = ctx.getState().createdPreprint.data?.id; + if (!createdPreprintId) return EMPTY; + + ctx.setState(patch({ subjects: patch({ isLoading: true }) })); + + return this.subjectsService.updatePreprintSubjects(createdPreprintId, subjects).pipe( + tap(() => { + ctx.patchState({ + subjects: { + data: subjects, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'subjects', error)) + ); + } + + @Action(DisconnectProject) + disconnectProject(ctx: StateContext) { + const createdPreprintId = ctx.getState().createdPreprint.data?.id; + if (!createdPreprintId) return EMPTY; + + ctx.setState(patch({ createdPreprint: patch({ isSubmitting: true }) })); + + return this.preprintProjectsService.removePreprintProjectRelationship(createdPreprintId).pipe( + tap(() => { + ctx.patchState({ + createdPreprint: { + ...ctx.getState().createdPreprint, + data: { + ...ctx.getState().createdPreprint.data!, + nodeId: null, + }, + isSubmitting: false, + }, + preprintProject: { + data: null, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'createdPreprint', error)) + ); + } + + @Action(ConnectProject) + connectProject(ctx: StateContext, { projectId }: ConnectProject) { + const createdPreprintId = ctx.getState().createdPreprint.data?.id; + if (!createdPreprintId) return EMPTY; + + ctx.setState(patch({ createdPreprint: patch({ isSubmitting: true }) })); + + return this.preprintProjectsService.updatePreprintProjectRelationship(createdPreprintId, projectId).pipe( + tap((preprint) => { + ctx.patchState({ + createdPreprint: { + data: preprint, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'createdPreprint', error)) + ); + } + + @Action(FetchPreprintProject) + fetchPreprintProject(ctx: StateContext) { + const preprintProjectId = ctx.getState().createdPreprint.data?.nodeId; + if (!preprintProjectId) return EMPTY; + + ctx.setState(patch({ preprintProject: patch({ isLoading: true }) })); + + return this.preprintProjectsService.getProjectById(preprintProjectId).pipe( + tap((project) => { + ctx.patchState({ + preprintProject: { + data: project, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'preprintProject', error)) + ); + } + + @Action(CreateNewProject) + createNewProject(ctx: StateContext, action: CreateNewProject) { + const createdPreprintId = ctx.getState().createdPreprint.data!.id; + ctx.setState(patch({ createdPreprint: patch({ isSubmitting: true }) })); + ctx.setState(patch({ preprintProject: patch({ isLoading: true }) })); + + return this.preprintProjectsService + .createProject(action.title, action.description, action.templateFrom, action.regionId, action.affiliationsId) + .pipe( + switchMap((project) => + forkJoin([ + of(project), + this.preprintProjectsService.updatePreprintProjectRelationship(createdPreprintId, project.id), + ]) + ), + tap(([project, preprint]) => { + ctx.patchState({ + createdPreprint: { + ...ctx.getState().createdPreprint, + data: { + ...ctx.getState().createdPreprint.data!, + nodeId: preprint.nodeId, + }, + isSubmitting: false, + }, + preprintProject: { + data: project, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'preprintProject', error)) + ); + } + private handleError( ctx: StateContext, section: keyof SubmitPreprintStateModel, diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html index 32e402b21..87384f84b 100644 --- a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html +++ b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html @@ -1,11 +1,7 @@ (); private readonly route = inject(ActivatedRoute); private readonly draftId = this.route.snapshot.params['id']; + private readonly OSF_PROVIDER_ID = 'osf'; - protected subjects = select(SubjectsSelectors.getSubjects); - protected subjectsLoading = select(SubjectsSelectors.getSubjectsLoading); - protected searchedSubjects = select(SubjectsSelectors.getSearchedSubjects); - protected isSearching = select(SubjectsSelectors.getSearchedSubjectsLoading); protected selectedSubjects = select(RegistriesSelectors.getSelectedSubjects); protected isSubjectsUpdating = select(RegistriesSelectors.isSubjectsUpdating); @@ -41,7 +37,7 @@ export class RegistriesSubjectsComponent { }); constructor() { - this.actions.fetchSubjects(); + this.actions.fetchSubjects(this.OSF_PROVIDER_ID); this.actions.fetchRegistrationSubjects(this.draftId); } diff --git a/src/app/features/registries/services/registration-subjects.service.ts b/src/app/features/registries/services/registration-subjects.service.ts index 2b6209ccd..697c5b656 100644 --- a/src/app/features/registries/services/registration-subjects.service.ts +++ b/src/app/features/registries/services/registration-subjects.service.ts @@ -13,7 +13,7 @@ export class RegistrationSubjectsService implements ISubjectsService { private apiUrl = environment.apiUrl; private readonly jsonApiService = inject(JsonApiService); - getSubjects(search?: string): Observable { + getSubjects(providerId: string, search?: string): Observable { const params: Record = { 'page[size]': '100', sort: 'text', @@ -26,7 +26,7 @@ export class RegistrationSubjectsService implements ISubjectsService { params['embed'] = 'parent'; } return this.jsonApiService - .get(`${this.apiUrl}/providers/registrations/osf/subjects/`, params) + .get(`${this.apiUrl}/providers/registrations/${providerId}/subjects/`, params) .pipe( map((response) => { return SubjectMapper.fromSubjectsResponseJsonApi(response); @@ -52,8 +52,13 @@ export class RegistrationSubjectsService implements ISubjectsService { } getRegistrationSubjects(draftId: string): Observable { + const params: Record = { + 'page[size]': '100', + page: '1', + }; + return this.jsonApiService - .get(`${this.apiUrl}/draft_registrations/${draftId}/subjects/`) + .get(`${this.apiUrl}/draft_registrations/${draftId}/subjects/`, params) .pipe( map((response) => { return SubjectMapper.fromSubjectsResponseJsonApi(response); diff --git a/src/app/shared/components/add-project-form/add-project-form.component.html b/src/app/shared/components/add-project-form/add-project-form.component.html index a39106ee3..67be60576 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.html +++ b/src/app/shared/components/add-project-form/add-project-form.component.html @@ -1,5 +1,4 @@ -
- +
-
+ />
- @if (affiliations().length) {
@@ -28,21 +28,17 @@

- + /> - + />
@@ -62,7 +58,6 @@

} -
-
-
- - -
- -
diff --git a/src/app/shared/components/add-project-form/add-project-form.component.ts b/src/app/shared/components/add-project-form/add-project-form.component.ts index 561f0559b..ee4e95fbe 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.ts @@ -1,27 +1,21 @@ -import { Store } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { ButtonModule } from 'primeng/button'; import { CheckboxModule } from 'primeng/checkbox'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { InputTextModule } from 'primeng/inputtext'; import { Select } from 'primeng/select'; import { Textarea } from 'primeng/textarea'; import { CommonModule, NgOptimizedImage } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, input, OnInit, signal } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants/my-projects-table.constants'; -import { STORAGE_LOCATIONS } from '@core/constants/storage-locations.constant'; -import { CreateProject, GetMyProjects, MyProjectsSelectors } from '@osf/features/my-projects/store'; -import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; -import { ProjectForm } from '@osf/shared/models/create-project-form.model'; -import { CustomValidators } from '@osf/shared/utils'; -import { InstitutionsSelectors } from '@shared/stores/institutions'; -import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; +import { ProjectFormControls } from '@osf/shared/enums'; +import { IdName, ProjectForm } from '@osf/shared/models'; +import { FetchUserInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; +import { FetchRegions, RegionsSelectors } from '@shared/stores/regions'; @Component({ selector: 'osf-add-project-form', @@ -41,93 +35,42 @@ import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AddProjectFormComponent implements OnInit { - #store = inject(Store); - protected readonly projects = this.#store.selectSignal(MyProjectsSelectors.getProjects); - protected readonly isMobile = toSignal(inject(IS_XSMALL)); - protected readonly dialogRef = inject(DynamicDialogRef); - protected readonly ProjectFormControls = ProjectFormControls; - protected readonly hasTemplateSelected = signal(false); - protected readonly isSubmitting = signal(false); - - protected readonly storageLocations = STORAGE_LOCATIONS; + private actions = createDispatchMap({ + fetchUserInstitutions: FetchUserInstitutions, + fetchRegions: FetchRegions, + }); - protected readonly affiliations = this.#store.selectSignal(InstitutionsSelectors.getUserInstitutions); + templates = input.required(); - protected projectTemplateOptions = computed(() => { - return this.projects().map((project) => ({ - label: project.title, - value: project.id, - })); - }); + ProjectFormControls = ProjectFormControls; - readonly projectForm = new FormGroup({ - [ProjectFormControls.Title]: new FormControl('', { - nonNullable: true, - validators: [CustomValidators.requiredTrimmed()], - }), - [ProjectFormControls.StorageLocation]: new FormControl('us', { - nonNullable: true, - validators: [Validators.required], - }), - [ProjectFormControls.Affiliations]: new FormControl([], { - nonNullable: true, - }), - [ProjectFormControls.Description]: new FormControl('', { - nonNullable: true, - }), - [ProjectFormControls.Template]: new FormControl('', { - nonNullable: true, - }), - }); + hasTemplateSelected = signal(false); + isSubmitting = signal(false); + storageLocations = select(RegionsSelectors.getRegions); + areStorageLocationsLoading = select(RegionsSelectors.areRegionsLoading); + affiliations = select(InstitutionsSelectors.getUserInstitutions); - constructor() { - this.projectForm.get(ProjectFormControls.Template)?.valueChanges.subscribe((value) => { - this.hasTemplateSelected.set(!!value); - }); - } + projectForm = input.required>(); ngOnInit(): void { - this.#store.dispatch(new GetMyProjects(1, MY_PROJECTS_TABLE_PARAMS.rows, {})); + this.actions.fetchUserInstitutions(); + this.actions.fetchRegions(); this.selectAllAffiliations(); + + this.projectForm() + .get(ProjectFormControls.Template) + ?.valueChanges.subscribe((value) => { + this.hasTemplateSelected.set(!!value); + }); } selectAllAffiliations(): void { const allAffiliationValues = this.affiliations().map((aff) => aff.id); - this.projectForm.get(ProjectFormControls.Affiliations)?.setValue(allAffiliationValues); + this.projectForm().get(ProjectFormControls.Affiliations)?.setValue(allAffiliationValues); } removeAllAffiliations(): void { - this.projectForm.get(ProjectFormControls.Affiliations)?.setValue([]); - } - - submitForm(): void { - if (!this.projectForm.valid) { - this.projectForm.markAllAsTouched(); - return; - } - - const formValue = this.projectForm.getRawValue(); - this.isSubmitting.set(true); - - this.#store - .dispatch( - new CreateProject( - formValue.title, - formValue.description, - formValue.template, - formValue.storageLocation, - formValue.affiliations - ) - ) - .subscribe({ - next: () => { - this.#store.dispatch(new GetMyProjects(1, MY_PROJECTS_TABLE_PARAMS.rows, {})); - this.dialogRef.close(); - }, - error: () => { - this.isSubmitting.set(false); - }, - }); + this.projectForm().get(ProjectFormControls.Affiliations)?.setValue([]); } } diff --git a/src/app/shared/components/subjects/subjects.component.html b/src/app/shared/components/subjects/subjects.component.html index aeec5abdb..8698217b6 100644 --- a/src/app/shared/components/subjects/subjects.component.html +++ b/src/app/shared/components/subjects/subjects.component.html @@ -50,7 +50,7 @@

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

selectionMode="checkbox" [selection]="selectedTree()" [lazy]="true" - [loading]="loading()" + [loading]="areSubjectsUpdating() || subjectsLoading()" (onNodeExpand)="loadNode($event.node)" (onNodeCollapse)="collapseNode($event.node)" (onNodeSelect)="selectSubject($event.node.data)" diff --git a/src/app/shared/components/subjects/subjects.component.ts b/src/app/shared/components/subjects/subjects.component.ts index 387172bfd..d3e7ee0c0 100644 --- a/src/app/shared/components/subjects/subjects.component.ts +++ b/src/app/shared/components/subjects/subjects.component.ts @@ -1,3 +1,5 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { TreeNode } from 'primeng/api'; @@ -15,6 +17,7 @@ import { FormControl } from '@angular/forms'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; import { Subject } from '@osf/shared/models'; +import { SubjectsSelectors } from '@shared/stores'; import { SearchInputComponent } from '../search-input/search-input.component'; @@ -26,17 +29,18 @@ import { SearchInputComponent } from '../search-input/search-input.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SubjectsComponent { + subjects = select(SubjectsSelectors.getSubjects); + subjectsLoading = select(SubjectsSelectors.getSubjectsLoading); + searchedSubjects = select(SubjectsSelectors.getSearchedSubjects); + areSubjectsUpdating = input(false); + isSearching = select(SubjectsSelectors.getSearchedSubjectsLoading); control = input>(); - list = input([]); - searchedSubjects = input([]); - loading = input(false); - isSearching = input(false); selected = input([]); searchChanged = output(); loadChildren = output(); updateSelection = output(); - subjectsTree = computed(() => this.list().map((subject: Subject) => this.mapSubjectToTreeNode(subject))); + subjectsTree = computed(() => this.subjects().map((subject: Subject) => this.mapSubjectToTreeNode(subject))); selectedTree = computed(() => this.selected().map((subject: Subject) => this.mapSubjectToTreeNode(subject))); searchedList = computed(() => this.searchedSubjects().map((subject: Subject) => this.mapParentsSubject(subject))); expanded: Record = {}; diff --git a/src/app/shared/mappers/regions/index.ts b/src/app/shared/mappers/regions/index.ts new file mode 100644 index 000000000..241a425e0 --- /dev/null +++ b/src/app/shared/mappers/regions/index.ts @@ -0,0 +1 @@ +export * from './regions-mapper'; diff --git a/src/app/shared/mappers/regions/regions-mapper.ts b/src/app/shared/mappers/regions/regions-mapper.ts new file mode 100644 index 000000000..9af72bb54 --- /dev/null +++ b/src/app/shared/mappers/regions/regions-mapper.ts @@ -0,0 +1,11 @@ +import { IdName } from '@shared/models'; +import { RegionsResponseJsonApi } from '@shared/models/regions'; + +export class RegionsMapper { + static fromRegionsResponseJsonApi(response: RegionsResponseJsonApi): IdName[] { + return response.data.map((data) => ({ + id: data.id, + name: data.attributes.name, + })); + } +} diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index d42f12aa2..08e4a449a 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -5,7 +5,6 @@ export * from './charts'; export * from './confirmation-options.model'; export * from './contributors'; export * from './create-component-form.model'; -export * from './create-project-form.model'; export * from './file-menu-action.model'; export * from './files/file.model'; export * from './files/get-files-response.model'; @@ -19,8 +18,9 @@ export * from './license.model'; export * from './license.model'; export * from './licenses-json-api.model'; export * from './metadata-field.model'; -export * from './node-response.model'; export * from './node-subject.model'; +export * from './nodes/create-project-form.model'; +export * from './nodes/nodes-json-api.model'; export * from './paginated-data.model'; export * from './query-params.model'; export * from './resource-card'; @@ -37,7 +37,6 @@ export * from './table-parameters.model'; export * from './toolbar-resource.model'; export * from './tooltip-position.model'; export * from './tutorial-step.model'; -export * from './update-node-request.model'; export * from './user'; export * from './validation-params.model'; export * from './view-only-links'; diff --git a/src/app/shared/models/create-project-form.model.ts b/src/app/shared/models/nodes/create-project-form.model.ts similarity index 86% rename from src/app/shared/models/create-project-form.model.ts rename to src/app/shared/models/nodes/create-project-form.model.ts index d34ae3b27..9eb174c5d 100644 --- a/src/app/shared/models/create-project-form.model.ts +++ b/src/app/shared/models/nodes/create-project-form.model.ts @@ -1,6 +1,6 @@ import { FormControl } from '@angular/forms'; -import { ProjectFormControls } from '@osf/shared/enums'; +import { ProjectFormControls } from '@shared/enums'; export interface ProjectForm { [ProjectFormControls.Title]: FormControl; diff --git a/src/app/shared/models/node-response.model.ts b/src/app/shared/models/nodes/nodes-json-api.model.ts similarity index 78% rename from src/app/shared/models/node-response.model.ts rename to src/app/shared/models/nodes/nodes-json-api.model.ts index abb41506e..e760308f5 100644 --- a/src/app/shared/models/node-response.model.ts +++ b/src/app/shared/models/nodes/nodes-json-api.model.ts @@ -105,3 +105,46 @@ export interface NodeResponseModel { data: NodeData; meta: NodeMeta; } + +export interface UpdateNodeAttributes { + description?: string; + tags?: string[]; + public?: boolean; + title?: string; +} + +export interface UpdateNodeData { + type: 'nodes'; + id: string; + attributes: UpdateNodeAttributes; +} + +export interface UpdateNodeRequestModel { + data: UpdateNodeData; +} + +export interface CreateProjectPayloadJsoApi { + data: { + type: 'nodes'; + attributes: { + title: string; + description?: string; + category: 'project'; + template_from?: string; + }; + relationships: { + region: { + data: { + type: 'regions'; + id: string; + }; + }; + affiliated_institutions?: { + data: { + type: 'institutions'; + id: string; + }[]; + }; + }; + }; +} diff --git a/src/app/shared/models/regions/index.ts b/src/app/shared/models/regions/index.ts new file mode 100644 index 000000000..a87050d50 --- /dev/null +++ b/src/app/shared/models/regions/index.ts @@ -0,0 +1 @@ +export * from './regions.json-api.model'; diff --git a/src/app/shared/models/regions/regions.json-api.model.ts b/src/app/shared/models/regions/regions.json-api.model.ts new file mode 100644 index 000000000..305c32a53 --- /dev/null +++ b/src/app/shared/models/regions/regions.json-api.model.ts @@ -0,0 +1,9 @@ +export interface RegionsResponseJsonApi { + data: { + id: string; + type: 'regions'; + attributes: { + name: string; + }; + }[]; +} diff --git a/src/app/shared/models/subject/subject-service.model.ts b/src/app/shared/models/subject/subject-service.model.ts index b9b5ae941..56d8abcb8 100644 --- a/src/app/shared/models/subject/subject-service.model.ts +++ b/src/app/shared/models/subject/subject-service.model.ts @@ -3,6 +3,7 @@ import { Observable } from 'rxjs'; import { Subject } from './subject.model'; export interface ISubjectsService { - getSubjects(search?: string): Observable; + getSubjects(providerId: string, search?: string): Observable; + getChildrenSubjects(parentId: string): Observable; } diff --git a/src/app/shared/models/update-node-request.model.ts b/src/app/shared/models/update-node-request.model.ts deleted file mode 100644 index db2ee5627..000000000 --- a/src/app/shared/models/update-node-request.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface UpdateNodeAttributes { - description?: string; - tags?: string[]; - public?: boolean; - title?: string; -} - -export interface UpdateNodeData { - type: 'nodes'; - id: string; - attributes: UpdateNodeAttributes; -} - -export interface UpdateNodeRequestModel { - data: UpdateNodeData; -} diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index 836b6e4cd..0bcf7434b 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -7,6 +7,7 @@ export { FiltersOptionsService } from './filters-options.service'; export { InstitutionsService } from './institutions.service'; export { LicensesService } from './licenses.service'; export { LoaderService } from './loader.service'; +export { RegionsService } from './regions.service'; export { ResourceCardService } from './resource-card.service'; export { SearchService } from './search.service'; export { SubjectsService } from './subjects.service'; diff --git a/src/app/shared/services/regions.service.ts b/src/app/shared/services/regions.service.ts new file mode 100644 index 000000000..4fb836929 --- /dev/null +++ b/src/app/shared/services/regions.service.ts @@ -0,0 +1,24 @@ +import { map, Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { RegionsMapper } from '@shared/mappers/regions'; +import { IdName } from '@shared/models'; +import { RegionsResponseJsonApi } from '@shared/models/regions'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class RegionsService { + private readonly http = inject(HttpClient); + private readonly baseUrl = environment.apiUrl; + + getAllRegions(): Observable { + return this.http + .get(`${this.baseUrl}/regions/`) + .pipe(map((regions) => RegionsMapper.fromRegionsResponseJsonApi(regions))); + } +} diff --git a/src/app/shared/stores/regions/index.ts b/src/app/shared/stores/regions/index.ts new file mode 100644 index 000000000..a3bb15b1a --- /dev/null +++ b/src/app/shared/stores/regions/index.ts @@ -0,0 +1,4 @@ +export * from './regions.actions'; +export * from './regions.model'; +export * from './regions.selectors'; +export * from './regions.state'; diff --git a/src/app/shared/stores/regions/regions.actions.ts b/src/app/shared/stores/regions/regions.actions.ts new file mode 100644 index 000000000..b6df2f670 --- /dev/null +++ b/src/app/shared/stores/regions/regions.actions.ts @@ -0,0 +1,3 @@ +export class FetchRegions { + static readonly type = '[Regions] Fetch Regions'; +} diff --git a/src/app/shared/stores/regions/regions.model.ts b/src/app/shared/stores/regions/regions.model.ts new file mode 100644 index 000000000..c7ec6e970 --- /dev/null +++ b/src/app/shared/stores/regions/regions.model.ts @@ -0,0 +1,5 @@ +import { AsyncStateModel, IdName } from '@shared/models'; + +export interface RegionsStateModel { + regions: AsyncStateModel; +} diff --git a/src/app/shared/stores/regions/regions.selectors.ts b/src/app/shared/stores/regions/regions.selectors.ts new file mode 100644 index 000000000..38453ccd2 --- /dev/null +++ b/src/app/shared/stores/regions/regions.selectors.ts @@ -0,0 +1,16 @@ +import { Selector } from '@ngxs/store'; + +import { RegionsStateModel } from './regions.model'; +import { RegionsState } from './regions.state'; + +export class RegionsSelectors { + @Selector([RegionsState]) + static getRegions(state: RegionsStateModel) { + return state.regions.data; + } + + @Selector([RegionsState]) + static areRegionsLoading(state: RegionsStateModel) { + return state.regions.isLoading; + } +} diff --git a/src/app/shared/stores/regions/regions.state.ts b/src/app/shared/stores/regions/regions.state.ts new file mode 100644 index 000000000..177b873c7 --- /dev/null +++ b/src/app/shared/stores/regions/regions.state.ts @@ -0,0 +1,39 @@ +import { Action, State, StateContext } from '@ngxs/store'; +import { patch } from '@ngxs/store/operators'; + +import { catchError, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@core/handlers'; +import { RegionsService } from '@shared/services'; + +import { FetchRegions } from './regions.actions'; +import { RegionsStateModel } from './regions.model'; + +@State({ + name: 'regions', + defaults: { + regions: { + data: [], + isLoading: false, + error: null, + }, + }, +}) +@Injectable() +export class RegionsState { + private readonly regionsService = inject(RegionsService); + + @Action(FetchRegions) + fetchSubjects(ctx: StateContext) { + ctx.setState(patch({ regions: patch({ isLoading: true }) })); + + return this.regionsService.getAllRegions().pipe( + tap((regions) => { + ctx.setState(patch({ regions: patch({ isLoading: false, data: regions }) })); + }), + catchError((error) => handleSectionError(ctx, 'regions', error)) + ); + } +} diff --git a/src/app/shared/stores/subjects/subjects.actions.ts b/src/app/shared/stores/subjects/subjects.actions.ts index d2a92eda6..6be7d53ca 100644 --- a/src/app/shared/stores/subjects/subjects.actions.ts +++ b/src/app/shared/stores/subjects/subjects.actions.ts @@ -4,6 +4,7 @@ export class GetSubjects { export class UpdateProjectSubjects { static readonly type = '[Subjects] Update Project'; + constructor( public projectId: string, public subjectIds: string[] @@ -12,10 +13,15 @@ export class UpdateProjectSubjects { export class FetchSubjects { static readonly type = '[Subjects] Fetch Subjects'; - constructor(public search?: string) {} + + constructor( + public providerId: string, + public search?: string + ) {} } export class FetchChildrenSubjects { static readonly type = '[Subjects] Fetch Children Subjects'; + constructor(public parentId: string) {} } diff --git a/src/app/shared/stores/subjects/subjects.state.ts b/src/app/shared/stores/subjects/subjects.state.ts index c1cbfadf6..7b30563ef 100644 --- a/src/app/shared/stores/subjects/subjects.state.ts +++ b/src/app/shared/stores/subjects/subjects.state.ts @@ -39,7 +39,7 @@ export class SubjectsState { private readonly subjectsService = inject(SUBJECTS_SERVICE); @Action(FetchSubjects) - fetchSubjects(ctx: StateContext, { search }: FetchSubjects) { + fetchSubjects(ctx: StateContext, { providerId, search }: FetchSubjects) { ctx.patchState({ subjects: { ...ctx.getState().subjects, @@ -53,7 +53,7 @@ export class SubjectsState { }, }); - return this.subjectsService.getSubjects(search).pipe( + return this.subjectsService.getSubjects(providerId, search).pipe( tap((subjects) => { if (search) { ctx.patchState({