diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index b0a0f9301..cde88005f 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -102,6 +102,11 @@ export const routes: Routes = [ path: 'my-projects/:id', loadChildren: () => import('./features/project/project.routes').then((mod) => mod.projectRoutes), }, + { + path: 'registries', + loadChildren: () => import('./features/registries/registries.routes').then((mod) => mod.registriesRoutes), + }, + { path: 'settings', loadChildren: () => import('./features/settings/settings.routes').then((mod) => mod.settingsRoutes), diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 271cd4d57..936f30cab 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -15,7 +15,6 @@ 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 { SubjectsState } from '@shared/stores/subjects'; export const STATES = [ AuthState, @@ -35,6 +34,5 @@ export const STATES = [ MeetingsState, RegistrationsState, ProjectMetadataState, - SubjectsState, LicensesState, ]; diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html new file mode 100644 index 000000000..3b1a04f44 --- /dev/null +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -0,0 +1,123 @@ +
+ @if (currentPage()) { +

{{ currentPage().title }}

+ + @let questions = currentPage().questions || []; + + @if (currentPage().sections?.length) { + @for (section of currentPage().sections; track section.id) { + questions = section.questions; + +

{{ section.title }}

+ @if (section.description) { +

{{ section.description }}

+ } + +
+ } + } @else { + + } + + + @for (question of questions; track question.id) { + + + @if (question.exampleText) { + + + {{ 'common.links.showExample' | translate }} + + +
+ +
+

{{ question.exampleText }}

+
+
+ } + + @switch (question.fieldType) { + @case (FieldType.TextArea) { + + } + @case (FieldType.Radio) { +
+ @for (option of question.options; track option) { +
+ + + @if (option.helpText) { + + } +
+ } +
+ } + @case (FieldType.Checkbox) { +
+ @for (option of question.options; track option) { +
+ + +
+ } +
+ } + + @case (FieldType.Text) { + + } + @case (FieldType.File) { +

Upload File

+

You may attach up to 5 file(s) to this question. Files cannot total over 5GB in size.

+

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

+ +

File input is not implemented yet.

+ } + } +
+ } +
+ } +
diff --git a/src/app/features/registries/components/custom-step/custom-step.component.scss b/src/app/features/registries/components/custom-step/custom-step.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts new file mode 100644 index 000000000..6c65cc772 --- /dev/null +++ b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CustomStepComponent } from './custom-step.component'; + +describe('CustomStepComponent', () => { + let component: CustomStepComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CustomStepComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CustomStepComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts new file mode 100644 index 000000000..fa37803cf --- /dev/null +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -0,0 +1,56 @@ +import { select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; +import { Checkbox } from 'primeng/checkbox'; +import { Inplace } from 'primeng/inplace'; +import { InputText } from 'primeng/inputtext'; +import { RadioButton } from 'primeng/radiobutton'; +import { Textarea } from 'primeng/textarea'; + +import { NgTemplateOutlet } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { InfoIconComponent } from '@osf/shared/components'; + +import { FieldType } from '../../enums'; +import { RegistriesSelectors } from '../../store'; + +@Component({ + selector: 'osf-custom-step', + imports: [ + Card, + Textarea, + RadioButton, + FormsModule, + Checkbox, + + InputText, + NgTemplateOutlet, + Inplace, + TranslatePipe, + InfoIconComponent, + ], + templateUrl: './custom-step.component.html', + styleUrl: './custom-step.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CustomStepComponent { + private readonly route = inject(ActivatedRoute); + step = signal(this.route.snapshot.params['step'].split('-')[0]); + protected readonly pages = select(RegistriesSelectors.getPagesSchema); + currentPage = computed(() => this.pages()[this.step() - 1]); + protected readonly FieldType = FieldType; + + radio = null; + + constructor() { + this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => { + this.step.set(+params['step'].split('-')[0]); + }); + } +} diff --git a/src/app/features/registries/components/drafts/drafts.component.html b/src/app/features/registries/components/drafts/drafts.component.html new file mode 100644 index 000000000..ca310305a --- /dev/null +++ b/src/app/features/registries/components/drafts/drafts.component.html @@ -0,0 +1,9 @@ + + + diff --git a/src/app/features/registries/components/drafts/drafts.component.scss b/src/app/features/registries/components/drafts/drafts.component.scss new file mode 100644 index 000000000..683ae23fa --- /dev/null +++ b/src/app/features/registries/components/drafts/drafts.component.scss @@ -0,0 +1,3 @@ +:host { + height: 100%; +} diff --git a/src/app/features/registries/components/drafts/drafts.component.spec.ts b/src/app/features/registries/components/drafts/drafts.component.spec.ts new file mode 100644 index 000000000..43a3cc2bb --- /dev/null +++ b/src/app/features/registries/components/drafts/drafts.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DraftsComponent } from './drafts.component'; + +describe('DraftsComponent', () => { + let component: DraftsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DraftsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DraftsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/drafts/drafts.component.ts b/src/app/features/registries/components/drafts/drafts.component.ts new file mode 100644 index 000000000..d99033fb4 --- /dev/null +++ b/src/app/features/registries/components/drafts/drafts.component.ts @@ -0,0 +1,102 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { tap } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, effect, inject, Signal, signal } from '@angular/core'; +import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; + +import { StepperComponent, SubHeaderComponent } from '@osf/shared/components'; +import { StepOption } from '@osf/shared/models'; +import { LoaderService } from '@osf/shared/services'; + +import { defaultSteps } from '../../constants'; +import { FetchDraft, FetchSchemaBlocks, RegistriesSelectors } from '../../store'; + +@Component({ + selector: 'osf-drafts', + imports: [RouterOutlet, StepperComponent, SubHeaderComponent, TranslatePipe], + templateUrl: './drafts.component.html', + styleUrl: './drafts.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [TranslateService], +}) +export class DraftsComponent { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly loaderService = inject(LoaderService); + private readonly translateService = inject(TranslateService); + + protected readonly pages = select(RegistriesSelectors.getPagesSchema); + protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + + private readonly actions = createDispatchMap({ + getSchemaBlocks: FetchSchemaBlocks, + getDraftRegistration: FetchDraft, + }); + + get isReviewPage(): boolean { + return this.router.url.includes('/review'); + } + + defaultSteps: StepOption[] = defaultSteps.map((step) => ({ + ...step, + label: this.translateService.instant(step.label), + })); + + steps: Signal = computed(() => { + const customSteps = this.pages().map((page) => ({ + label: page.title, + value: page.id, + })); + return [this.defaultSteps[0], ...customSteps, this.defaultSteps[1]]; + }); + + currentStep = signal( + this.route.snapshot.children[0]?.params['step'] ? +this.route.snapshot.children[0]?.params['step'].split('-')[0] : 0 + ); + + registrationId = this.route.snapshot.children[0]?.params['id'] || ''; + + constructor() { + this.loaderService.show(); + if (!this.draftRegistration()) { + this.actions.getDraftRegistration(this.registrationId); + } + effect(() => { + const registrationSchemaId = this.draftRegistration()?.registrationSchemaId; + if (registrationSchemaId) { + this.actions + .getSchemaBlocks(registrationSchemaId || '') + .pipe( + tap(() => { + this.loaderService.hide(); + }) + ) + .subscribe(); + } + }); + + effect(() => { + const reviewStepNumber = this.pages().length + 1; + if (this.isReviewPage) { + this.currentStep.set(reviewStepNumber); + } + }); + } + + stepChange(step: number): void { + // [NM] TODO: before navigating, validate the current step + this.currentStep.set(step); + const pageStep = this.steps()[step]; + + let pageLink = ''; + if (!pageStep.value) { + pageLink = `${pageStep.routeLink}`; + } else { + pageLink = `${step}-${pageStep.value}`; + } + this.router.navigate([`/registries/drafts/${this.registrationId}/`, pageLink]); + } +} diff --git a/src/app/features/registries/components/metadata/contributors/contributors.component.html b/src/app/features/registries/components/metadata/contributors/contributors.component.html new file mode 100644 index 000000000..256af9e13 --- /dev/null +++ b/src/app/features/registries/components/metadata/contributors/contributors.component.html @@ -0,0 +1,25 @@ + +

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

+ +
+ @if (hasChanges) { +
+ + +
+ } + +
+
diff --git a/src/app/features/registries/components/metadata/contributors/contributors.component.scss b/src/app/features/registries/components/metadata/contributors/contributors.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/components/metadata/contributors/contributors.component.spec.ts b/src/app/features/registries/components/metadata/contributors/contributors.component.spec.ts new file mode 100644 index 000000000..fb45f2cde --- /dev/null +++ b/src/app/features/registries/components/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/registries/components/metadata/contributors/contributors.component.ts b/src/app/features/registries/components/metadata/contributors/contributors.component.ts new file mode 100644 index 000000000..916180a12 --- /dev/null +++ b/src/app/features/registries/components/metadata/contributors/contributors.component.ts @@ -0,0 +1,212 @@ +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 { TableModule } from 'primeng/table'; + +import { filter, forkJoin, map, of } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { + AddContributor, + DeleteContributor, + FetchContributors, + RegistriesSelectors, + UpdateContributor, +} from '@osf/features/registries/store'; +import { EducationHistoryDialogComponent, EmploymentHistoryDialogComponent } from '@osf/shared/components'; +import { + AddContributorDialogComponent, + AddUnregisteredContributorDialogComponent, + ContributorsListComponent, +} from '@osf/shared/components/contributors'; +import { BIBLIOGRAPHY_OPTIONS, PERMISSION_OPTIONS } from '@osf/shared/components/contributors/constants'; +import { AddContributorType, ContributorPermission } from '@osf/shared/components/contributors/enums'; +import { ContributorDialogAddModel, ContributorModel } from '@osf/shared/components/contributors/models'; +import { ContributorsSelectors } from '@osf/shared/components/contributors/store'; +import { SelectOption } from '@osf/shared/models'; +import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { findChangedItems } from '@osf/shared/utils'; + +@Component({ + selector: 'osf-contributors', + imports: [FormsModule, TableModule, ContributorsListComponent, TranslatePipe, Card, Button], + 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); + + private readonly route = inject(ActivatedRoute); + private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); + + protected readonly selectedPermission = signal(null); + protected readonly selectedBibliography = signal(null); + protected readonly permissionsOptions: SelectOption[] = PERMISSION_OPTIONS; + protected readonly bibliographyOptions: SelectOption[] = BIBLIOGRAPHY_OPTIONS; + + protected initialContributors = select(RegistriesSelectors.getContributors); + protected contributors = signal([]); + + protected readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + + 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 { + const draftId = this.draftId(); + if (draftId) { + this.actions.getContributors(draftId); + } + } + + onFocusOut() { + // [NM] TODO: make request to update contributor if changed + console.log('Focus out event:', 'Changed:', this.hasChanges); + } + + 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(this.draftId(), 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(this.draftId(), 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(this.draftId(), 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(this.draftId(), contributor.userId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => + this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { + name: contributor.fullName, + }), + }); + }, + }); + } +} diff --git a/src/app/features/registries/components/metadata/metadata.component.html b/src/app/features/registries/components/metadata/metadata.component.html new file mode 100644 index 000000000..5c44bd71e --- /dev/null +++ b/src/app/features/registries/components/metadata/metadata.component.html @@ -0,0 +1,37 @@ +
+
+

{{ 'registries.metadata.title' | translate }}

+

{{ 'registries.metadata.description' | translate }}

+ +
+ + +
+ + +
+
+
+
+
+ + + + +
+ + + +
+
diff --git a/src/app/features/registries/components/metadata/metadata.component.scss b/src/app/features/registries/components/metadata/metadata.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/components/metadata/metadata.component.spec.ts b/src/app/features/registries/components/metadata/metadata.component.spec.ts new file mode 100644 index 000000000..311ac4e9f --- /dev/null +++ b/src/app/features/registries/components/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/registries/components/metadata/metadata.component.ts b/src/app/features/registries/components/metadata/metadata.component.ts new file mode 100644 index 000000000..d7b289ec1 --- /dev/null +++ b/src/app/features/registries/components/metadata/metadata.component.ts @@ -0,0 +1,92 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { TextareaModule } from 'primeng/textarea'; + +import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { TextInputComponent } from '@osf/shared/components'; +import { InputLimits } from '@osf/shared/constants'; +import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { CustomValidators } from '@osf/shared/utils'; + +import { DeleteDraft, RegistriesSelectors } from '../../store'; + +import { ContributorsComponent } from './contributors/contributors.component'; +import { RegistriesLicenseComponent } from './registries-license/registries-license.component'; +import { RegistriesSubjectsComponent } from './registries-subjects/registries-subjects.component'; +import { RegistriesTagsComponent } from './registries-tags/registries-tags.component'; + +@Component({ + selector: 'osf-metadata', + imports: [ + Card, + TextInputComponent, + ReactiveFormsModule, + Button, + TranslatePipe, + TextareaModule, + ContributorsComponent, + RegistriesSubjectsComponent, + RegistriesTagsComponent, + RegistriesLicenseComponent, + ], + templateUrl: './metadata.component.html', + styleUrl: './metadata.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetadataComponent { + private readonly fb = inject(FormBuilder); + private readonly toastService = inject(ToastService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly customConfirmationService = inject(CustomConfirmationService); + + private readonly draftId = this.route.snapshot.params['id']; + protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + + protected actions = createDispatchMap({ + deleteDraft: DeleteDraft, + }); + protected inputLimits = InputLimits; + + metadataForm = this.fb.group({ + title: ['', CustomValidators.requiredTrimmed()], + description: ['', CustomValidators.requiredTrimmed()], + }); + + constructor() { + effect(() => { + const draft = this.draftRegistration(); + if (draft) { + this.metadataForm.patchValue({ + title: draft.title, + description: draft.description, + }); + } + }); + } + + submitMetadata(): void { + console.log('Metadata submitted'); + } + + deleteDraft(): void { + this.customConfirmationService.confirmDelete({ + headerKey: 'registries.deleteDraft', + messageKey: 'registries.confirmDeleteDraft', + onConfirm: () => { + this.actions.deleteDraft(this.draftId).subscribe({ + next: () => { + this.router.navigateByUrl('/registries/new'); + }, + }); + }, + }); + } +} diff --git a/src/app/features/registries/components/metadata/registries-license/registries-license.component.html b/src/app/features/registries/components/metadata/registries-license/registries-license.component.html new file mode 100644 index 000000000..cc5f9c25a --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-license/registries-license.component.html @@ -0,0 +1,47 @@ + +

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

+

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

+

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

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

+ +

+ } +
diff --git a/src/app/features/registries/components/metadata/registries-license/registries-license.component.scss b/src/app/features/registries/components/metadata/registries-license/registries-license.component.scss new file mode 100644 index 000000000..7f863186d --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-license/registries-license.component.scss @@ -0,0 +1,4 @@ +.highlight-block { + padding: 0.5rem; + background-color: var(--bg-blue-2); +} diff --git a/src/app/features/registries/components/metadata/registries-license/registries-license.component.spec.ts b/src/app/features/registries/components/metadata/registries-license/registries-license.component.spec.ts new file mode 100644 index 000000000..b4f491478 --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-license/registries-license.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistriesLicenseComponent } from './registries-license.component'; + +describe('LicenseComponent', () => { + let component: RegistriesLicenseComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistriesLicenseComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesLicenseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/metadata/registries-license/registries-license.component.ts b/src/app/features/registries/components/metadata/registries-license/registries-license.component.ts new file mode 100644 index 000000000..d81e74502 --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-license/registries-license.component.ts @@ -0,0 +1,63 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; +import { DatePicker } from 'primeng/datepicker'; +import { Divider } from 'primeng/divider'; +import { Select } from 'primeng/select'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { License } from '@osf/features/registries/models'; +import { FetchLicenses, RegistriesSelectors } from '@osf/features/registries/store'; +import { TextInputComponent, TruncatedTextComponent } from '@osf/shared/components'; +import { InputLimits } from '@osf/shared/constants'; +import { InterpolatePipe } from '@osf/shared/pipes'; +import { CustomValidators } from '@osf/shared/utils'; + +@Component({ + selector: 'osf-registries-license', + imports: [ + Card, + TranslatePipe, + Select, + FormsModule, + Divider, + TruncatedTextComponent, + DatePicker, + TextInputComponent, + InterpolatePipe, + ReactiveFormsModule, + ], + templateUrl: './registries-license.component.html', + styleUrl: './registries-license.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistriesLicenseComponent { + private readonly route = inject(ActivatedRoute); + private readonly draftId = this.route.snapshot.params['id']; + private readonly fb = inject(FormBuilder); + + protected actions = createDispatchMap({ fetchLicenses: FetchLicenses }); + protected licenses = select(RegistriesSelectors.getLicenses); + protected inputLimits = InputLimits; + + selectedLicense: License | null = null; + currentYear = new Date(); + licenseYear = this.currentYear; + licenseForm = this.fb.group({ + year: [this.currentYear.getFullYear().toString(), CustomValidators.requiredTrimmed()], + copyrightHolders: ['', CustomValidators.requiredTrimmed()], + }); + + constructor() { + this.actions.fetchLicenses(); + } + + onSelectLicense(license: License): void { + console.log('Selected License:', license); + } +} 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 new file mode 100644 index 000000000..1e83c52cd --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html @@ -0,0 +1,10 @@ + diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.scss b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.spec.ts b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.spec.ts new file mode 100644 index 000000000..c6b0b212d --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistriesSubjectsComponent } from './registries-subjects.component'; + +describe('RegistriesSubjectsComponent', () => { + let component: RegistriesSubjectsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistriesSubjectsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesSubjectsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts new file mode 100644 index 000000000..719f8e668 --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts @@ -0,0 +1,57 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { + FetchRegistrationSubjects, + RegistriesSelectors, + UpdateRegistrationSubjects, +} from '@osf/features/registries/store'; +import { SubjectsComponent } from '@osf/shared/components'; +import { Subject } from '@osf/shared/models'; +import { FetchChildrenSubjects, FetchSubjects } from '@osf/shared/stores'; +import { SubjectsSelectors } from '@osf/shared/stores/subjects/subjects.selectors'; + +@Component({ + selector: 'osf-registries-subjects', + imports: [SubjectsComponent], + templateUrl: './registries-subjects.component.html', + styleUrl: './registries-subjects.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistriesSubjectsComponent { + private readonly route = inject(ActivatedRoute); + private readonly draftId = this.route.snapshot.params['id']; + + 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); + + protected actions = createDispatchMap({ + fetchSubjects: FetchSubjects, + fetchRegistrationSubjects: FetchRegistrationSubjects, + fetchChildrenSubjects: FetchChildrenSubjects, + updateRegistrationSubjects: UpdateRegistrationSubjects, + }); + + constructor() { + this.actions.fetchSubjects(); + this.actions.fetchRegistrationSubjects(this.draftId); + } + + getSubjectChildren(parentId: string) { + this.actions.fetchChildrenSubjects(parentId); + } + + searchSubjects(search: string) { + this.actions.fetchSubjects(search); + } + + updateSelectedSubjects(subjects: Subject[]) { + this.actions.updateRegistrationSubjects(this.draftId, subjects); + } +} diff --git a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.html b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.html new file mode 100644 index 000000000..2d8798d8a --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.html @@ -0,0 +1,7 @@ + +
+

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

+ + +
+
diff --git a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.scss b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.spec.ts b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.spec.ts new file mode 100644 index 000000000..3ec731631 --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistriesTagsComponent } from './registries-tags.component'; + +describe('TagsComponent', () => { + let component: RegistriesTagsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistriesTagsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesTagsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.ts b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.ts new file mode 100644 index 000000000..bc0ca52f9 --- /dev/null +++ b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.ts @@ -0,0 +1,20 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { TagsInputComponent } from '@osf/shared/components'; + +@Component({ + selector: 'osf-registries-tags', + imports: [Card, TagsInputComponent, TranslatePipe], + templateUrl: './registries-tags.component.html', + styleUrl: './registries-tags.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistriesTagsComponent { + onTagsChanged(tags: string[]): void { + console.log('Tags changed:', tags); + } +} diff --git a/src/app/features/registries/components/new-registration/new-registration.component.html b/src/app/features/registries/components/new-registration/new-registration.component.html new file mode 100644 index 000000000..d9b7ce6ef --- /dev/null +++ b/src/app/features/registries/components/new-registration/new-registration.component.html @@ -0,0 +1,73 @@ + +
+

+ {{ 'registries.new.infoText1' | translate }} + {{ 'common.links.clickHere' | translate }} + {{ 'registries.new.infoText2' | translate }} +

+
+
+ +

{{ ('registries.new.steps.title' | translate) + '1' }}

+

{{ 'registries.new.steps.step1' | translate }}

+
+ + +
+
+
+ @if (fromProject) { + +

{{ ('registries.new.steps.title' | translate) + '2' }}

+

{{ 'registries.new.steps.step2' | translate }}

+

{{ 'registries.new.steps.step2InfoText' | translate }}

+
+ +
+
+ } + +

{{ ('registries.new.steps.title' | translate) + (fromProject ? '3' : '2') }}

+

{{ 'registries.new.steps.step3' | translate }}

+
+ +
+
+
+ +
+
+
diff --git a/src/app/features/registries/components/new-registration/new-registration.component.scss b/src/app/features/registries/components/new-registration/new-registration.component.scss new file mode 100644 index 000000000..da0c027b5 --- /dev/null +++ b/src/app/features/registries/components/new-registration/new-registration.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; +} diff --git a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts new file mode 100644 index 000000000..b6c711a95 --- /dev/null +++ b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NewRegistrationComponent } from './new-registration.component'; + +describe('NewRegistrationComponent', () => { + let component: NewRegistrationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NewRegistrationComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(NewRegistrationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts new file mode 100644 index 000000000..de7fbc31a --- /dev/null +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -0,0 +1,90 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { Select } from 'primeng/select'; + +import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { SubHeaderComponent } from '@osf/shared/components'; +import { ToastService } from '@osf/shared/services'; + +import { CreateDraft, GetProjects, GetProviders, RegistriesSelectors } from '../../store'; + +@Component({ + selector: 'osf-new-registration', + imports: [SubHeaderComponent, TranslatePipe, Card, Button, ReactiveFormsModule, Select], + templateUrl: './new-registration.component.html', + styleUrl: './new-registration.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NewRegistrationComponent { + private readonly fb = inject(FormBuilder); + private readonly toastService = inject(ToastService); + private readonly router = inject(Router); + protected readonly projects = select(RegistriesSelectors.getProjects); + protected readonly providers = select(RegistriesSelectors.getProviders); + protected readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); + protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + protected readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); + protected actions = createDispatchMap({ + getProjects: GetProjects, + getProviders: GetProviders, + createDraft: CreateDraft, + }); + fromProject = false; + + draftForm = this.fb.group({ + provider: ['', Validators.required], + project: [''], + }); + + constructor() { + this.actions.getProjects(); + this.actions.getProviders(); + effect(() => { + const provider = this.draftForm.get('provider')?.value; + if (!provider) { + this.draftForm.get('provider')?.setValue(this.providers()[0]?.id); + } + }); + } + + onSelectProject(projectId: string) { + this.draftForm.patchValue({ + project: projectId, + }); + } + + onSelectProvider(providerId: string) { + this.draftForm.patchValue({ + provider: providerId, + }); + } + + toggleFromProject() { + this.fromProject = !this.fromProject; + this.draftForm.get('project')?.setValidators(this.fromProject ? Validators.required : null); + this.draftForm.get('project')?.updateValueAndValidity(); + } + + createDraft() { + const { provider, project } = this.draftForm.value; + + if (this.draftForm.valid) { + this.actions + .createDraft({ + registrationSchemaId: provider!, + projectId: this.fromProject ? (project ?? undefined) : undefined, + }) + .subscribe(() => { + this.toastService.showSuccess('Draft created successfully'); + this.router.navigate(['/registries/drafts/', this.draftRegistration()?.id, 'metadata']); + }); + } + } +} diff --git a/src/app/features/registries/components/review/review.component.html b/src/app/features/registries/components/review/review.component.html new file mode 100644 index 000000000..27a7cd2a1 --- /dev/null +++ b/src/app/features/registries/components/review/review.component.html @@ -0,0 +1 @@ +

review works!

diff --git a/src/app/features/registries/components/review/review.component.scss b/src/app/features/registries/components/review/review.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/components/review/review.component.spec.ts b/src/app/features/registries/components/review/review.component.spec.ts new file mode 100644 index 000000000..f555378f7 --- /dev/null +++ b/src/app/features/registries/components/review/review.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ReviewComponent } from './review.component'; + +describe('ReviewComponent', () => { + let component: ReviewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ReviewComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ReviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/review/review.component.ts b/src/app/features/registries/components/review/review.component.ts new file mode 100644 index 000000000..e38cb2e4e --- /dev/null +++ b/src/app/features/registries/components/review/review.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'osf-review', + imports: [], + templateUrl: './review.component.html', + styleUrl: './review.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReviewComponent {} diff --git a/src/app/features/registries/constants/defaultSteps.ts b/src/app/features/registries/constants/defaultSteps.ts new file mode 100644 index 000000000..ba7808628 --- /dev/null +++ b/src/app/features/registries/constants/defaultSteps.ts @@ -0,0 +1,14 @@ +import { StepOption } from '@osf/shared/models'; + +export const defaultSteps: StepOption[] = [ + { + label: 'navigation.project.metadata', + value: '', + routeLink: 'metadata', + }, + { + label: 'registries.review.step', + value: '', + routeLink: 'review', + }, +]; diff --git a/src/app/features/registries/constants/index.ts b/src/app/features/registries/constants/index.ts new file mode 100644 index 000000000..92e572c61 --- /dev/null +++ b/src/app/features/registries/constants/index.ts @@ -0,0 +1 @@ +export * from './defaultSteps'; diff --git a/src/app/features/registries/enums/block-type.enum.ts b/src/app/features/registries/enums/block-type.enum.ts new file mode 100644 index 000000000..4240e5321 --- /dev/null +++ b/src/app/features/registries/enums/block-type.enum.ts @@ -0,0 +1,13 @@ +export enum BlockType { + PageHeading = 'page-heading', + SubsectionHeading = 'subsection-heading', + QuestionLabel = 'question-label', + LongTextInput = 'long-text-input', + Paragraph = 'paragraph', + SingleSelectInput = 'single-select-input', + SelectInputOption = 'select-input-option', + FileInput = 'file-input', + MultiSelectInput = 'multi-select-input', + ShortTextInput = 'short-text-input', + SectionHeading = 'section-heading', +} diff --git a/src/app/features/registries/enums/field-type.enum.ts b/src/app/features/registries/enums/field-type.enum.ts new file mode 100644 index 000000000..114128096 --- /dev/null +++ b/src/app/features/registries/enums/field-type.enum.ts @@ -0,0 +1,8 @@ +export enum FieldType { + Text = 'text', + TextArea = 'textarea', + Select = 'select', + Checkbox = 'checkbox', + Radio = 'radio', + File = 'file', +} diff --git a/src/app/features/registries/enums/index.ts b/src/app/features/registries/enums/index.ts new file mode 100644 index 000000000..609b32864 --- /dev/null +++ b/src/app/features/registries/enums/index.ts @@ -0,0 +1,2 @@ +export * from './block-type.enum'; +export * from './field-type.enum'; diff --git a/src/app/features/registries/mappers/index.ts b/src/app/features/registries/mappers/index.ts new file mode 100644 index 000000000..013703b21 --- /dev/null +++ b/src/app/features/registries/mappers/index.ts @@ -0,0 +1,4 @@ +export * from './licenses.mapper'; +export * from './page-schema.mapper'; +export * from './projects.mapper'; +export * from './providers.mapper'; diff --git a/src/app/features/registries/mappers/licenses.mapper.ts b/src/app/features/registries/mappers/licenses.mapper.ts new file mode 100644 index 000000000..9ad0a8290 --- /dev/null +++ b/src/app/features/registries/mappers/licenses.mapper.ts @@ -0,0 +1,13 @@ +import { License, LicensesResponseJsonApi } from '../models'; + +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/features/registries/mappers/page-schema.mapper.ts b/src/app/features/registries/mappers/page-schema.mapper.ts new file mode 100644 index 000000000..6bfd884fa --- /dev/null +++ b/src/app/features/registries/mappers/page-schema.mapper.ts @@ -0,0 +1,139 @@ +import { BlockType, FieldType } from '../enums'; +import { PageSchema, Question, Section } from '../models'; +import { SchemaBlocksResponseJsonApi } from '../models/schema-blocks-json-api.model'; + +export class PageSchemaMapper { + static fromSchemaBlocksResponse(response: SchemaBlocksResponseJsonApi): PageSchema[] { + const pages: PageSchema[] = []; + let currentPage!: PageSchema; + let currentQuestion: Question | null = null; + let currentSection: Section | null = null; + response.data.map((item) => { + switch (item.attributes.block_type) { + case BlockType.PageHeading: + currentPage = { + id: item.id, + title: item.attributes.display_text, + questions: [], + }; + currentQuestion = null; + currentSection = null; + pages.push(currentPage); + break; + case BlockType.SectionHeading: + if (currentPage) { + currentSection = { + id: item.id, + title: item.attributes.display_text, + questions: [], + }; + currentPage.sections = currentPage.sections || []; + currentPage.sections.push(currentSection); + currentQuestion = null; + } + break; + + case BlockType.Paragraph: + if (currentQuestion) { + currentQuestion.paragraphText = item.attributes.display_text; + } else if (currentSection) { + currentSection.description = item.attributes.display_text; + } else { + currentPage.description = item.attributes.display_text; + } + break; + + case BlockType.SubsectionHeading: + currentQuestion = { + id: item.id, + displayText: item.attributes.display_text, + helpText: item.attributes.help_text, + exampleText: item.attributes.example_text, + required: item.attributes.required, + groupKey: item.attributes.schema_block_group_key, + responseKey: item.attributes.registration_response_key || undefined, + }; + if (currentSection) { + currentSection.questions = currentSection.questions || []; + currentSection.questions.push(currentQuestion); + } else if (currentPage) { + currentPage.questions = currentPage.questions || []; + currentPage.questions.push(currentQuestion); + } + break; + + case BlockType.QuestionLabel: + currentQuestion = { + id: item.id, + displayText: item.attributes.display_text, + helpText: item.attributes.help_text, + exampleText: item.attributes.example_text, + required: item.attributes.required, + groupKey: item.attributes.schema_block_group_key, + responseKey: item.attributes.registration_response_key || undefined, + }; + if (currentSection) { + currentSection.questions = currentSection.questions || []; + currentSection.questions.push(currentQuestion); + } else if (currentPage) { + currentPage.questions = currentPage.questions || []; + currentPage.questions.push(currentQuestion); + } + break; + + case BlockType.SingleSelectInput: + if (currentQuestion) { + currentQuestion.fieldType = FieldType.Radio; + currentQuestion.required = item.attributes.required; + currentQuestion.responseKey = item.attributes.registration_response_key || undefined; + } + break; + + case BlockType.MultiSelectInput: + if (currentQuestion) { + currentQuestion.fieldType = FieldType.Checkbox; + currentQuestion.required = item.attributes.required; + currentQuestion.responseKey = item.attributes.registration_response_key || undefined; + } + break; + + case BlockType.SelectInputOption: + if (currentQuestion) { + currentQuestion.options = currentQuestion?.options || []; + currentQuestion?.options.push({ + label: item.attributes.display_text, + value: item.attributes.display_text, + helpText: item.attributes.help_text, + }); + } + break; + + case BlockType.LongTextInput: + if (currentQuestion) { + currentQuestion.fieldType = FieldType.TextArea; + currentQuestion.required = item.attributes.required; + currentQuestion.responseKey = item.attributes.registration_response_key || undefined; + } + break; + case BlockType.ShortTextInput: + if (currentQuestion) { + currentQuestion.fieldType = FieldType.Text; + currentQuestion.required = item.attributes.required; + currentQuestion.responseKey = item.attributes.registration_response_key || undefined; + } + break; + + case BlockType.FileInput: + if (currentQuestion) { + currentQuestion.fieldType = FieldType.File; + } + break; + default: + console.warn(`Unexpected block type: ${item.attributes.block_type}`); + return; + } + }); + + return pages; + } +} diff --git a/src/app/features/registries/mappers/projects.mapper.ts b/src/app/features/registries/mappers/projects.mapper.ts new file mode 100644 index 000000000..df0729851 --- /dev/null +++ b/src/app/features/registries/mappers/projects.mapper.ts @@ -0,0 +1,10 @@ +import { Project, ProjectsResponseJsonApi } from '../models'; + +export class ProjectsMapper { + static fromProjectsResponse(response: ProjectsResponseJsonApi): Project[] { + return response.data.map((item) => ({ + id: item.id, + title: item.attributes.title, + })); + } +} diff --git a/src/app/features/registries/mappers/providers.mapper.ts b/src/app/features/registries/mappers/providers.mapper.ts new file mode 100644 index 000000000..9442ade0a --- /dev/null +++ b/src/app/features/registries/mappers/providers.mapper.ts @@ -0,0 +1,10 @@ +import { Provider, ProvidersResponseJsonApi } from '../models'; + +export class ProvidersMapper { + static fromProvidersResponse(response: ProvidersResponseJsonApi): Provider[] { + return response.data.map((item) => ({ + id: item.id, + name: item.attributes.name, + })); + } +} diff --git a/src/app/features/registries/mappers/registration.mapper.ts b/src/app/features/registries/mappers/registration.mapper.ts new file mode 100644 index 000000000..9ae2e1bd2 --- /dev/null +++ b/src/app/features/registries/mappers/registration.mapper.ts @@ -0,0 +1,13 @@ +import { RegistrationDataJsonApi } from '../models'; +import { Registration } from '../models/registration.model'; + +export class RegistrationMapper { + static fromRegistrationResponse(response: RegistrationDataJsonApi): Registration { + return { + id: response.id, + title: response.attributes.title, + description: response.attributes.description, + registrationSchemaId: response.relationships.registration_schema?.data?.id || '', + }; + } +} diff --git a/src/app/features/registries/models/index.ts b/src/app/features/registries/models/index.ts new file mode 100644 index 000000000..69c4c8b7c --- /dev/null +++ b/src/app/features/registries/models/index.ts @@ -0,0 +1,10 @@ +export * from './license.model'; +export * from './licenses-json-api.model'; +export * from './page-schema.model'; +export * from './project'; +export * from './projects-json-api.model'; +export * from './provider.model'; +export * from './providers-json-api.model'; +export * from './registration.model'; +export * from './registration-json-api.model'; +export * from './schema-blocks-json-api.model'; diff --git a/src/app/features/registries/models/license.model.ts b/src/app/features/registries/models/license.model.ts new file mode 100644 index 000000000..c4a243a7f --- /dev/null +++ b/src/app/features/registries/models/license.model.ts @@ -0,0 +1,7 @@ +export interface License { + id: string; + name: string; + requiredFields: string[]; + url: string; + text: string; +} diff --git a/src/app/features/registries/models/licenses-json-api.model.ts b/src/app/features/registries/models/licenses-json-api.model.ts new file mode 100644 index 000000000..32e8fc049 --- /dev/null +++ b/src/app/features/registries/models/licenses-json-api.model.ts @@ -0,0 +1,16 @@ +import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '@osf/core/models'; + +export interface LicensesResponseJsonApi { + data: LicenseDataJsonApi[]; + meta: MetaJsonApi; + links: PaginationLinksJsonApi; +} + +export type LicenseDataJsonApi = ApiData; + +interface LicenseAttributesJsonApi { + name: string; + required_fields: string[]; + url: string; + text: string; +} diff --git a/src/app/features/registries/models/page-schema.model.ts b/src/app/features/registries/models/page-schema.model.ts new file mode 100644 index 000000000..84ad1f13d --- /dev/null +++ b/src/app/features/registries/models/page-schema.model.ts @@ -0,0 +1,35 @@ +import { FieldType } from '../enums'; + +export interface PageSchema { + id: string; + title: string; + description?: string; + questions?: Question[]; + sections?: Section[]; +} + +export interface Section { + id: string; + title: string; + description?: string; + questions?: Question[]; +} + +export interface Question { + id: string; + displayText: string; + exampleText?: string; + helpText?: string; + paragraphText?: string; + fieldType?: FieldType; + options?: QuestionOption[]; + required: boolean; + groupKey?: string; + responseKey?: string; +} + +export interface QuestionOption { + label: string; + value: string; + helpText?: string; +} diff --git a/src/app/features/registries/models/project.ts b/src/app/features/registries/models/project.ts new file mode 100644 index 000000000..870a40589 --- /dev/null +++ b/src/app/features/registries/models/project.ts @@ -0,0 +1,4 @@ +export interface Project { + id: string; + title: string; +} diff --git a/src/app/features/registries/models/projects-json-api.model.ts b/src/app/features/registries/models/projects-json-api.model.ts new file mode 100644 index 000000000..0ce2e620a --- /dev/null +++ b/src/app/features/registries/models/projects-json-api.model.ts @@ -0,0 +1,13 @@ +import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '@osf/core/models'; + +export interface ProjectsResponseJsonApi { + data: ProjectsDataJsonApi[]; + meta: MetaJsonApi; + links: PaginationLinksJsonApi; +} + +export type ProjectsDataJsonApi = ApiData; + +interface ProjectsAttributesJsonApi { + title: string; +} diff --git a/src/app/features/registries/models/provider.model.ts b/src/app/features/registries/models/provider.model.ts new file mode 100644 index 000000000..aa49d5327 --- /dev/null +++ b/src/app/features/registries/models/provider.model.ts @@ -0,0 +1,4 @@ +export interface Provider { + id: string; + name: string; +} diff --git a/src/app/features/registries/models/providers-json-api.model.ts b/src/app/features/registries/models/providers-json-api.model.ts new file mode 100644 index 000000000..1b69240ff --- /dev/null +++ b/src/app/features/registries/models/providers-json-api.model.ts @@ -0,0 +1,13 @@ +import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '@osf/core/models'; + +export interface ProvidersResponseJsonApi { + data: ProviderDataJsonApi[]; + meta: MetaJsonApi; + links: PaginationLinksJsonApi; +} + +export type ProviderDataJsonApi = ApiData; + +interface ProviderAttributesJsonApi { + name: string; +} diff --git a/src/app/features/registries/models/registration-json-api.model.ts b/src/app/features/registries/models/registration-json-api.model.ts new file mode 100644 index 000000000..f581282e6 --- /dev/null +++ b/src/app/features/registries/models/registration-json-api.model.ts @@ -0,0 +1,37 @@ +import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '@osf/core/models'; + +export interface RegistrationResponseJsonApi { + data: RegistrationDataJsonApi; + meta: MetaJsonApi; + links: PaginationLinksJsonApi; +} + +export type RegistrationDataJsonApi = ApiData< + RegistrationAttributesJsonApi, + null, + RegistrationRelationshipsJsonApi, + null +>; + +interface RegistrationAttributesJsonApi { + category: string; + current_user_permissions: string[]; + date_created: string; + datetime_updated: string; + description: string; + has_project: boolean; + node_license: string | null; + registration_metadata: Record; + registration_responses: Record; + tags: string[]; + title: string; +} + +interface RegistrationRelationshipsJsonApi { + registration_schema: { + data: { + id: '58fd62fcda3e2400012ca5c1'; + type: 'registration-schemas'; + }; + }; +} diff --git a/src/app/features/registries/models/registration.model.ts b/src/app/features/registries/models/registration.model.ts new file mode 100644 index 000000000..6a717fc94 --- /dev/null +++ b/src/app/features/registries/models/registration.model.ts @@ -0,0 +1,6 @@ +export interface Registration { + id: string; + title: string; + description: string; + registrationSchemaId: string; +} diff --git a/src/app/features/registries/models/schema-blocks-json-api.model.ts b/src/app/features/registries/models/schema-blocks-json-api.model.ts new file mode 100644 index 000000000..796eab5d1 --- /dev/null +++ b/src/app/features/registries/models/schema-blocks-json-api.model.ts @@ -0,0 +1,22 @@ +import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '@osf/core/models'; + +import { BlockType } from '../enums'; + +export interface SchemaBlocksResponseJsonApi { + data: SchemaBlockJsonApi[]; + meta: MetaJsonApi; + links: PaginationLinksJsonApi; +} + +export type SchemaBlockJsonApi = ApiData; + +interface SchemaBlockAttributesJsonApi { + block_type: BlockType; + display_text: string; + example_text: string; + help_text: string; + index: number; + registration_response_key: string | null; + required: boolean; + schema_block_group_key: string; +} diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.html b/src/app/features/registries/pages/registries-landing/registries-landing.component.html index 475cedd14..506e40dcb 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.html +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.html @@ -6,6 +6,7 @@ [description]="'registries.description' | translate" [showButton]="true" [buttonLabel]="'registries.addRegistration' | translate" + (buttonClick)="goToCreateRegistration()" /> + import('./components/new-registration/new-registration.component').then( + (mod) => mod.NewRegistrationComponent + ), + }, + { + path: 'drafts', + loadComponent: () => import('./components/drafts/drafts.component').then((mod) => mod.DraftsComponent), + children: [ + { + path: ':id/metadata', + loadComponent: () => + import('./components/metadata/metadata.component').then((mod) => mod.MetadataComponent), + }, + { + path: ':id/review', + loadComponent: () => import('./components/review/review.component').then((mod) => mod.ReviewComponent), + }, + { + path: ':id/:step', + loadComponent: () => + import('./components/custom-step/custom-step.component').then((mod) => mod.CustomStepComponent), + }, + ], + }, ], }, ]; diff --git a/src/app/features/registries/services/index.ts b/src/app/features/registries/services/index.ts new file mode 100644 index 000000000..99fc8de16 --- /dev/null +++ b/src/app/features/registries/services/index.ts @@ -0,0 +1,5 @@ +export * from './licenses.service'; +export * from './projects.service'; +export * from './providers.service'; +export * from './registration-subjects.service'; +export * from './registries.service'; diff --git a/src/app/features/registries/services/licenses.service.ts b/src/app/features/registries/services/licenses.service.ts new file mode 100644 index 000000000..8879ed1c7 --- /dev/null +++ b/src/app/features/registries/services/licenses.service.ts @@ -0,0 +1,52 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@osf/core/services'; + +import { LicensesMapper } from '../mappers'; +import { License, LicensesResponseJsonApi } from '../models'; + +import { environment } from 'src/environments/environment'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const data: any = { + data: [ + { + id: '58fd62fdda3e2400012ca5d9', + type: 'licenses', + attributes: { + name: 'MIT License', + text: 'The MIT License (MIT)\n\nCopyright (c) {{year}} {{copyrightHolders}}\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the "Software"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n', + url: 'http://opensource.org/licenses/MIT', + required_fields: ['year', 'copyrightHolders'], + }, + links: { + self: 'https://api.test.osf.io/v2/licenses/58fd62fdda3e2400012ca5d9/', + }, + }, + ], +}; + +@Injectable({ + providedIn: 'root', +}) +export class LicensesService { + private apiUrl = environment.apiUrl; + private readonly jsonApiService = inject(JsonApiService); + + getLicenses(): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/providers/registrations/osf/licenses/`, { + params: { + 'page[size]': 100, + }, + }) + .pipe( + map((licenses) => { + licenses.data.unshift(data.data[0]); // For testing purposes, remove in production + return LicensesMapper.fromLicensesResponse(licenses); + }) + ); + } +} diff --git a/src/app/features/registries/services/projects.service.ts b/src/app/features/registries/services/projects.service.ts new file mode 100644 index 000000000..414e5e497 --- /dev/null +++ b/src/app/features/registries/services/projects.service.ts @@ -0,0 +1,28 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@osf/core/services'; + +import { ProjectsMapper } from '../mappers/projects.mapper'; +import { Project } from '../models'; +import { ProjectsResponseJsonApi } from '../models/projects-json-api.model'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class ProjectsService { + private apiUrl = environment.apiUrl; + private readonly jsonApiService = inject(JsonApiService); + + getProjects(): Observable { + const params: Record = { + 'filter[current_user_permissions]': 'admin', + }; + return this.jsonApiService + .get(`${this.apiUrl}/users/me/nodes/`, params) + .pipe(map((response) => ProjectsMapper.fromProjectsResponse(response))); + } +} diff --git a/src/app/features/registries/services/providers.service.ts b/src/app/features/registries/services/providers.service.ts new file mode 100644 index 000000000..721234bd8 --- /dev/null +++ b/src/app/features/registries/services/providers.service.ts @@ -0,0 +1,25 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiService } from '@osf/core/services'; + +import { ProvidersMapper } from '../mappers/providers.mapper'; +import { Provider } from '../models'; +import { ProvidersResponseJsonApi } from '../models/providers-json-api.model'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class ProvidersService { + private apiUrl = environment.apiUrl; + private readonly jsonApiService = inject(JsonApiService); + + getProviders(): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/providers/registrations/osf/schemas/`) + .pipe(map((response) => ProvidersMapper.fromProvidersResponse(response))); + } +} diff --git a/src/app/features/registries/services/registration-subjects.service.ts b/src/app/features/registries/services/registration-subjects.service.ts new file mode 100644 index 000000000..7f6f4f618 --- /dev/null +++ b/src/app/features/registries/services/registration-subjects.service.ts @@ -0,0 +1,73 @@ +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 RegistrationSubjectsService implements ISubjectsService { + private apiUrl = environment.apiUrl; + private readonly jsonApiService = inject(JsonApiService); + + getSubjects(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/registrations/osf/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); + }) + ); + } + + getRegistrationSubjects(draftId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/draft_registrations/${draftId}/subjects/`) + .pipe( + map((response) => { + return SubjectMapper.fromSubjectsResponseJsonApi(response); + }) + ); + } + + updateRegistrationSubjects(draftId: string, subjects: Subject[]): Observable { + const payload = { + data: subjects.map((item) => ({ id: item.id, type: 'subjects' })), + }; + + return this.jsonApiService.put(`${this.apiUrl}/draft_registrations/${draftId}/relationships/subjects/`, payload); + } +} diff --git a/src/app/features/registries/services/registries.service.ts b/src/app/features/registries/services/registries.service.ts new file mode 100644 index 000000000..5a89f9e4c --- /dev/null +++ b/src/app/features/registries/services/registries.service.ts @@ -0,0 +1,115 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiResponse } from '@osf/core/models'; +import { JsonApiService } from '@osf/core/services'; +import { AddContributorType } from '@osf/shared/components/contributors/enums'; +import { ContributorsMapper } from '@osf/shared/components/contributors/mappers'; +import { ContributorAddModel, ContributorModel, ContributorResponse } from '@osf/shared/components/contributors/models'; + +import { PageSchemaMapper } from '../mappers'; +import { RegistrationMapper } from '../mappers/registration.mapper'; +import { + PageSchema, + Registration, + RegistrationDataJsonApi, + RegistrationResponseJsonApi, + SchemaBlocksResponseJsonApi, +} from '../models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class RegistriesService { + private apiUrl = environment.apiUrl; + private readonly jsonApiService = inject(JsonApiService); + + createDraft(registrationSchemaId: string, projectId?: string | undefined): Observable { + const payload = { + data: { + type: 'draft_registrations', + relationships: { + branched_from: projectId + ? { + data: { + type: 'nodes', + id: projectId, + }, + } + : undefined, + registration_schema: { + data: { + type: 'registration-schemas', + id: registrationSchemaId, + }, + }, + }, + }, + }; + return this.jsonApiService + .post(`${this.apiUrl}/draft_registrations/`, payload) + .pipe(map((response) => RegistrationMapper.fromRegistrationResponse(response))); + } + + getDraft(draftId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/draft_registrations/${draftId}/`) + .pipe(map((response) => RegistrationMapper.fromRegistrationResponse(response.data))); + } + + updateDraft(draftId: string, data: Registration): Observable { + const payload = { + data: { + id: draftId, + type: 'draft_registrations', + attributes: { ...data }, + relationships: {}, + }, + }; + return this.jsonApiService.patch(`${this.apiUrl}/draft_registrations/${draftId}/`, payload); + } + + deleteDraft(draftId: string): Observable { + return this.jsonApiService.delete(`${this.apiUrl}/draft_registrations/${draftId}/`); + } + + getSchemaBlocks(registrationSchemaId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/schemas/registrations/${registrationSchemaId}/schema_blocks/`) + .pipe(map((response) => PageSchemaMapper.fromSchemaBlocksResponse(response))); + } + + getContributors(draftId: string): Observable { + return this.jsonApiService + .get>(`${this.apiUrl}/draft_registrations/${draftId}/contributors/`) + .pipe(map((contributors) => ContributorsMapper.fromResponse(contributors.data))); + } + + addContributor(draftId: string, data: ContributorAddModel): Observable { + const baseUrl = `${this.apiUrl}/draft_registrations/${draftId}/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(draftId: string, data: ContributorModel): Observable { + const baseUrl = `${environment.apiUrl}/draft_registrations/${draftId}/contributors/${data.userId}`; + + const contributorData = { data: ContributorsMapper.toContributorAddRequest(data) }; + + return this.jsonApiService + .patch(baseUrl, contributorData) + .pipe(map((contributor) => ContributorsMapper.fromContributorResponse(contributor))); + } + + deleteContributor(draftId: string, contributorId: string): Observable { + return this.jsonApiService.delete(`${this.apiUrl}/draft_registrations/${draftId}/contributors/${contributorId}`); + } +} diff --git a/src/app/features/registries/store/registries.actions.ts b/src/app/features/registries/store/registries.actions.ts index 91edcdf7f..bc0e1579e 100644 --- a/src/app/features/registries/store/registries.actions.ts +++ b/src/app/features/registries/store/registries.actions.ts @@ -1,3 +1,84 @@ +import { ContributorAddModel, ContributorModel } from '@osf/shared/components/contributors/models'; +import { Subject } from '@osf/shared/models'; + export class GetRegistries { static readonly type = '[Registries] Get Registries'; } + +export class GetProviders { + static readonly type = '[Registries] Get Providers'; +} + +export class GetProjects { + static readonly type = '[Registries] Get Projects'; +} + +export class CreateDraft { + static readonly type = '[Registries] Create Draft'; + constructor(public payload: { registrationSchemaId: string; projectId?: string }) {} +} + +export class FetchDraft { + static readonly type = '[Registries] Fetch Draft'; + constructor(public draftId: string) {} +} + +export class DeleteDraft { + static readonly type = '[Registries] Delete Draft'; + constructor(public draftId: string) {} +} + +export class FetchSchemaBlocks { + static readonly type = '[Registries] Fetch Schema Blocks'; + constructor(public registrationSchemaId: string) {} +} + +export class FetchContributors { + static readonly type = '[Registries] Fetch Contributors'; + + constructor(public draftId: string) {} +} + +export class AddContributor { + static readonly type = '[Registries] Add Contributor'; + + constructor( + public draftId: string, + public contributor: ContributorAddModel + ) {} +} + +export class UpdateContributor { + static readonly type = '[Registries] Update Contributor'; + + constructor( + public draftId: string, + public contributor: ContributorModel + ) {} +} + +export class DeleteContributor { + static readonly type = '[Registries] Delete Contributor'; + + constructor( + public draftId: string, + public contributorId: string + ) {} +} + +export class FetchLicenses { + static readonly type = '[Registries] Fetch Licenses'; +} + +export class FetchRegistrationSubjects { + static readonly type = '[Registries] Fetch Registration Subjects'; + constructor(public registrationId: string) {} +} + +export class UpdateRegistrationSubjects { + static readonly type = '[Registries] Update Registration Subject'; + constructor( + public registrationId: string, + public subjects: Subject[] + ) {} +} diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index 42de4eea6..2ce1f6202 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -1,5 +1,16 @@ -import { AsyncStateModel, Resource } from '@shared/models'; +import { ContributorModel } from '@osf/shared/components/contributors/models'; +import { AsyncStateModel, Resource, Subject } from '@shared/models'; + +import { License, PageSchema, Project, Provider } from '../models'; +import { Registration } from '../models/registration.model'; export interface RegistriesStateModel { + providers: AsyncStateModel; + projects: AsyncStateModel; + draftRegistration: AsyncStateModel; + contributorsList: AsyncStateModel; registries: AsyncStateModel; + licenses: AsyncStateModel; + registrationSubjects: AsyncStateModel; + pagesSchema: AsyncStateModel; } diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 4a3812a4e..4ea91666a 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -1,10 +1,48 @@ import { Selector } from '@ngxs/store'; -import { RegistriesStateModel } from '@osf/features/registries/store/registries.model'; -import { RegistriesState } from '@osf/features/registries/store/registries.state'; -import { Resource } from '@shared/models'; +import { Resource, Subject } from '@shared/models'; + +import { License, PageSchema, Project, Provider, Registration } from '../models'; + +import { RegistriesStateModel } from './registries.model'; +import { RegistriesState } from './registries.state'; export class RegistriesSelectors { + @Selector([RegistriesState]) + static getProviders(state: RegistriesStateModel): Provider[] { + return state.providers.data; + } + + @Selector([RegistriesState]) + static isProvidersLoading(state: RegistriesStateModel): boolean { + return state.providers.isLoading; + } + + @Selector([RegistriesState]) + static getProjects(state: RegistriesStateModel): Project[] { + return state.projects.data; + } + + @Selector([RegistriesState]) + static isDraftSubmitting(state: RegistriesStateModel): boolean { + return state.draftRegistration.isSubmitting ?? false; + } + + @Selector([RegistriesState]) + static getDraftRegistration(state: RegistriesStateModel): Registration | null { + return state.draftRegistration.data; + } + + @Selector([RegistriesState]) + static getRegistrationLoading(state: RegistriesStateModel): boolean { + return state.draftRegistration.isLoading || state.draftRegistration.isSubmitting || state.pagesSchema.isLoading; + } + + @Selector([RegistriesState]) + static getContributors(state: RegistriesStateModel) { + return state.contributorsList.data; + } + @Selector([RegistriesState]) static getRegistries(state: RegistriesStateModel): Resource[] { return state.registries.data; @@ -14,4 +52,24 @@ export class RegistriesSelectors { static isRegistriesLoading(state: RegistriesStateModel): boolean { return state.registries.isLoading; } + + @Selector([RegistriesState]) + static getLicenses(state: RegistriesStateModel): License[] { + return state.licenses.data; + } + + @Selector([RegistriesState]) + static getPagesSchema(state: RegistriesStateModel): PageSchema[] { + return state.pagesSchema.data; + } + + @Selector([RegistriesState]) + static getSelectedSubjects(state: RegistriesStateModel): Subject[] { + return state.registrationSubjects.data; + } + + @Selector([RegistriesState]) + static isSubjectsUpdating(state: RegistriesStateModel): boolean { + return state.registrationSubjects.isLoading; + } } diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index f6b384660..9cf274cad 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -1,30 +1,97 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { tap, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; -import { ResourceTab } from '@shared/enums'; -import { SearchService } from '@shared/services'; -import { getResourceTypes } from '@shared/utils'; +import { ResourceTab } from '@osf/shared/enums'; +import { SearchService } from '@osf/shared/services'; +import { getResourceTypes } from '@osf/shared/utils'; -import { GetRegistries } from './registries.actions'; +import { Project } from '../models'; +import { + LicensesService, + ProjectsService, + ProvidersService, + RegistrationSubjectsService, + RegistriesService, +} from '../services'; + +import { + AddContributor, + CreateDraft, + DeleteContributor, + DeleteDraft, + FetchContributors, + FetchDraft, + FetchLicenses, + FetchRegistrationSubjects, + FetchSchemaBlocks, + GetProjects, + GetProviders, + GetRegistries, + UpdateContributor, + UpdateRegistrationSubjects, +} from './registries.actions'; import { RegistriesStateModel } from './registries.model'; -@Injectable() +const DefaultState: RegistriesStateModel = { + providers: { + data: [], + isLoading: false, + error: null, + }, + projects: { + data: [], + isLoading: false, + error: null, + }, + draftRegistration: { + isLoading: false, + data: null, + isSubmitting: false, + error: null, + }, + contributorsList: { + data: [], + isLoading: false, + error: null, + }, + registries: { + data: [], + isLoading: false, + error: null, + }, + licenses: { + data: [], + isLoading: false, + error: null, + }, + registrationSubjects: { + data: [], + isLoading: false, + error: null, + }, + pagesSchema: { + data: [], + isLoading: false, + error: null, + }, +}; + @State({ name: 'registries', - defaults: { - registries: { - data: [], - isLoading: false, - error: null, - }, - }, + defaults: { ...DefaultState }, }) +@Injectable() export class RegistriesState { searchService = inject(SearchService); + providersService = inject(ProvidersService); + projectsService = inject(ProjectsService); + registriesService = inject(RegistriesService); + licensesService = inject(LicensesService); + subjectsService = inject(RegistrationSubjectsService); @Action(GetRegistries) getRegistries(ctx: StateContext) { @@ -52,7 +119,332 @@ export class RegistriesState { ); } - private handleError(ctx: StateContext, section: 'registries', error: Error) { + @Action(GetProjects) + getProjects({ patchState }: StateContext) { + patchState({ + projects: { + ...DefaultState.projects, + isLoading: true, + }, + }); + return this.projectsService.getProjects().subscribe({ + next: (projects: Project[]) => { + patchState({ + projects: { + data: projects, + isLoading: false, + error: null, + }, + }); + }, + error: (error) => { + patchState({ + projects: { ...DefaultState.projects, isLoading: false, error }, + }); + }, + }); + } + + @Action(GetProviders) + getProviders({ patchState }: StateContext) { + patchState({ + providers: { + ...DefaultState.providers, + isLoading: true, + }, + }); + return this.providersService.getProviders().subscribe({ + next: (providers) => { + patchState({ + providers: { + data: providers, + isLoading: false, + error: null, + }, + }); + }, + error: (error) => { + patchState({ + providers: { + ...DefaultState.providers, + isLoading: false, + error, + }, + }); + }, + }); + } + + @Action(CreateDraft) + createDraft(ctx: StateContext, { payload }: CreateDraft) { + ctx.patchState({ + draftRegistration: { + ...ctx.getState().draftRegistration, + isSubmitting: true, + }, + }); + + return this.registriesService.createDraft(payload.registrationSchemaId, payload.projectId).pipe( + tap((registration) => { + ctx.patchState({ + draftRegistration: { + data: { ...registration }, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }), + catchError((error) => { + ctx.patchState({ + draftRegistration: { + ...ctx.getState().draftRegistration, + isSubmitting: false, + error: error.message, + }, + }); + return this.handleError(ctx, 'draftRegistration', error); + }) + ); + } + + @Action(FetchDraft) + fetchDraft(ctx: StateContext, { draftId }: FetchDraft) { + ctx.patchState({ + draftRegistration: { + ...ctx.getState().draftRegistration, + isLoading: true, + error: null, + }, + }); + + return this.registriesService.getDraft(draftId).pipe( + tap((draft) => { + ctx.patchState({ + draftRegistration: { + data: { ...draft }, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'draftRegistration', error)) + ); + } + + @Action(DeleteDraft) + deleteDraft(ctx: StateContext, { draftId }: DeleteDraft) { + ctx.patchState({ + draftRegistration: { + ...ctx.getState().draftRegistration, + isSubmitting: true, + }, + }); + + return this.registriesService.deleteDraft(draftId).pipe( + tap(() => { + ctx.patchState({ + draftRegistration: { + ...ctx.getState().draftRegistration, + isSubmitting: false, + data: null, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'draftRegistration', error)) + ); + } + + @Action(FetchSchemaBlocks) + fetchSchemaBlocks(ctx: StateContext, action: FetchSchemaBlocks) { + const state = ctx.getState(); + ctx.patchState({ + pagesSchema: { ...state.pagesSchema, isLoading: true, error: null }, + }); + return this.registriesService.getSchemaBlocks(action.registrationSchemaId).pipe( + tap((schemaBlocks) => { + ctx.patchState({ + pagesSchema: { + data: schemaBlocks, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'pagesSchema', error)) + ); + } + + @Action(FetchContributors) + fetchContributors(ctx: StateContext, action: FetchContributors) { + const state = ctx.getState(); + + ctx.patchState({ + contributorsList: { ...state.contributorsList, isLoading: true, error: null }, + }); + + return this.registriesService.getContributors(action.draftId).pipe( + tap((contributors) => { + ctx.patchState({ + contributorsList: { + ...state.contributorsList, + data: contributors, + isLoading: false, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'contributorsList', error)) + ); + } + + @Action(AddContributor) + addContributor(ctx: StateContext, action: AddContributor) { + const state = ctx.getState(); + + ctx.patchState({ + contributorsList: { ...state.contributorsList, isLoading: true, error: null }, + }); + + return this.registriesService.addContributor(action.draftId, action.contributor).pipe( + tap((contributor) => { + const currentState = ctx.getState(); + + ctx.patchState({ + contributorsList: { + ...currentState.contributorsList, + data: [...currentState.contributorsList.data, contributor], + isLoading: false, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'contributorsList', error)) + ); + } + + @Action(UpdateContributor) + updateContributor(ctx: StateContext, action: UpdateContributor) { + const state = ctx.getState(); + + ctx.patchState({ + contributorsList: { ...state.contributorsList, isLoading: true, error: null }, + }); + + return this.registriesService.updateContributor(action.draftId, action.contributor).pipe( + tap((updatedContributor) => { + const currentState = ctx.getState(); + + ctx.patchState({ + contributorsList: { + ...currentState.contributorsList, + data: currentState.contributorsList.data.map((contributor) => + contributor.id === updatedContributor.id ? updatedContributor : contributor + ), + isLoading: false, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'contributorsList', error)) + ); + } + + @Action(DeleteContributor) + deleteContributor(ctx: StateContext, action: DeleteContributor) { + const state = ctx.getState(); + + ctx.patchState({ + contributorsList: { ...state.contributorsList, isLoading: true, error: null }, + }); + + return this.registriesService.deleteContributor(action.draftId, action.contributorId).pipe( + tap(() => { + ctx.patchState({ + contributorsList: { + ...state.contributorsList, + data: state.contributorsList.data.filter((contributor) => contributor.userId !== action.contributorId), + isLoading: false, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'contributorsList', error)) + ); + } + + @Action(FetchLicenses) + fetchLicenses(ctx: StateContext) { + ctx.patchState({ + licenses: { + ...ctx.getState().licenses, + isLoading: true, + }, + }); + + return this.licensesService.getLicenses().pipe( + tap((licenses) => { + ctx.patchState({ + licenses: { + data: licenses, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'licenses', error)) + ); + } + + @Action(FetchRegistrationSubjects) + fetchRegistrationSubjects(ctx: StateContext, { registrationId }: FetchRegistrationSubjects) { + ctx.patchState({ + registrationSubjects: { + ...ctx.getState().registrationSubjects, + isLoading: true, + error: null, + }, + }); + + return this.subjectsService.getRegistrationSubjects(registrationId).pipe( + tap((subjects) => { + ctx.patchState({ + registrationSubjects: { + data: subjects, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'registrationSubjects', error)) + ); + } + + @Action(UpdateRegistrationSubjects) + updateRegistrationSubjects( + ctx: StateContext, + { registrationId, subjects }: UpdateRegistrationSubjects + ) { + ctx.patchState({ + registrationSubjects: { + ...ctx.getState().registrationSubjects, + isLoading: true, + error: null, + }, + }); + return this.subjectsService.updateRegistrationSubjects(registrationId, subjects).pipe( + tap(() => { + ctx.patchState({ + registrationSubjects: { + data: subjects, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'registrationSubjects', error)) + ); + } + + private handleError(ctx: StateContext, section: keyof RegistriesStateModel, error: Error) { ctx.patchState({ [section]: { ...ctx.getState()[section], diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index 4609a2226..5d97044c1 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -25,6 +25,7 @@ export { SearchInputComponent } from './search-input/search-input.component'; export { SelectComponent } from './select/select.component'; export { StepperComponent } from './stepper/stepper.component'; export { SubHeaderComponent } from './sub-header/sub-header.component'; +export { SubjectsComponent } from './subjects/subjects.component'; export { TagsInputComponent } from './tags-input/tags-input.component'; export { TextInputComponent } from './text-input/text-input.component'; export { ToastComponent } from './toast/toast.component'; diff --git a/src/app/shared/components/stepper/stepper.component.html b/src/app/shared/components/stepper/stepper.component.html index b647786b1..39c6c28c5 100644 --- a/src/app/shared/components/stepper/stepper.component.html +++ b/src/app/shared/components/stepper/stepper.component.html @@ -1,9 +1,19 @@
@for (step of steps(); track step.value; let i = $index) { -