From 46d004031d40d8a2ea03f8614010c4954985203a Mon Sep 17 00:00:00 2001 From: NazarMykhalkevych Date: Tue, 8 Jul 2025 12:13:20 +0300 Subject: [PATCH] feat(registries): validate and save steps data --- .../custom-step/custom-step.component.html | 214 ++++++++++-------- .../custom-step/custom-step.component.ts | 124 +++++++++- .../components/drafts/drafts.component.ts | 3 +- .../registries/mappers/registration.mapper.ts | 1 + .../registries/models/registration.model.ts | 2 + .../registries/store/registries.selectors.ts | 5 + .../components/stepper/stepper.component.html | 12 +- 7 files changed, 257 insertions(+), 104 deletions(-) diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index 3b1a04f44..f05e1d9c6 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -21,103 +21,139 @@

{{ section.title }}

@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) { - - } + @if (question.paragraphText) { +

{{ question.paragraphText }}

+ } + + @if (question.exampleText) { + + + {{ 'common.links.showExample' | translate }} + + +
+
- } -
+

{{ question.exampleText }}

+ + } - @case (FieldType.Checkbox) { -
- @for (option of question.options; track option) { -
- - -
+ + @switch (question.fieldType) { + @case (FieldType.TextArea) { + + @if ( + stepForm.controls[question.responseKey!].errors?.['required'] && + (stepForm.controls[question.responseKey!].touched || stepForm.controls[question.responseKey!].dirty) + ) { + + {{ INPUT_VALIDATION_MESSAGES.required | translate }} + } -
- } + } + @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. -

+ @case (FieldType.Text) { + + @if ( + stepForm.controls[question.responseKey!].errors?.['required'] && + (stepForm.controls[question.responseKey!].touched || stepForm.controls[question.responseKey!].dirty) + ) { + + {{ INPUT_VALIDATION_MESSAGES.required | translate }} + + } + } + @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.

+

File input is not implemented yet.

+ } } - } - + + } } +
+ + +
diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 930272fa8..d9c4fe06b 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -1,24 +1,29 @@ -import { select } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; +import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Checkbox } from 'primeng/checkbox'; import { Inplace } from 'primeng/inplace'; import { InputText } from 'primeng/inputtext'; +import { Message } from 'primeng/message'; 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 { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components'; +import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; +import { CustomValidators, findChangedFields } from '@osf/shared/utils'; import { FieldType } from '../../enums'; -import { RegistriesSelectors } from '../../store'; +import { PageSchema } from '../../models'; +import { RegistriesSelectors, UpdateDraft, UpdateStepValidation } from '../../store'; @Component({ selector: 'osf-custom-step', @@ -28,12 +33,15 @@ import { RegistriesSelectors } from '../../store'; RadioButton, FormsModule, Checkbox, - + TranslatePipe, InputText, NgTemplateOutlet, Inplace, TranslatePipe, InfoIconComponent, + Button, + ReactiveFormsModule, + Message, ], templateUrl: './custom-step.component.html', styleUrl: './custom-step.component.scss', @@ -41,16 +49,116 @@ import { RegistriesSelectors } from '../../store'; }) export class CustomStepComponent { private readonly route = inject(ActivatedRoute); - step = signal(this.route.snapshot.params['step']); + private readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + protected readonly pages = select(RegistriesSelectors.getPagesSchema); - currentPage = computed(() => this.pages()[this.step() - 1]); protected readonly FieldType = FieldType; + protected readonly stepsData = select(RegistriesSelectors.getStepsData); + protected stepsValidation = select(RegistriesSelectors.getStepsValidation); + + protected actions = createDispatchMap({ + updateDraft: UpdateDraft, + updateStepValidation: UpdateStepValidation, + }); + + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + + step = signal(this.route.snapshot.params['step']); + currentPage = computed(() => this.pages()[this.step() - 1]); radio = null; + stepForm!: FormGroup; + constructor() { this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => { + this.updateStepState(); this.step.set(+params['step']); }); + + effect(() => { + const page = this.currentPage(); + if (page) { + this.initStepForm(page); + } + }); + } + + private initStepForm(page: PageSchema): void { + this.stepForm = this.fb.group({}); + + page.questions?.forEach((q) => { + const controlName = q.responseKey as string; + let control: FormControl; + + switch (q.fieldType) { + case FieldType.Text: + case FieldType.TextArea: + control = this.fb.control(this.stepsData()[controlName], { + validators: q.required ? [CustomValidators.requiredTrimmed()] : [], + }); + break; + + case FieldType.Checkbox: + control = this.fb.control(this.stepsData()[controlName] || [], { + validators: q.required ? [Validators.required] : [], + }); + break; + + case FieldType.Radio: + case FieldType.Select: + control = this.fb.control(this.stepsData()[controlName], { + validators: q.required ? [Validators.required] : [], + }); + break; + + default: + console.warn(`Unsupported field type: ${q.fieldType}`); + return; + } + + this.stepForm.addControl(controlName, control); + }); + if (this.stepsValidation()?.[this.step()]?.invalid) { + this.stepForm.markAllAsTouched(); + } + } + + private updateDraft() { + const changedFields = findChangedFields(this.stepForm.value, this.stepsData()); + if (Object.keys(changedFields).length > 0) { + const draftId = this.route.snapshot.params['id']; + const attributes = { + registration_responses: this.stepForm.value, + }; + this.actions.updateDraft(draftId, attributes); + } + } + + private updateStepState() { + if (this.stepForm) { + this.updateDraft(); + this.stepForm.markAllAsTouched(); + this.actions.updateStepValidation(this.step(), this.stepForm.invalid); + } + } + + goBack(): void { + const previousStep = this.step() - 1; + if (previousStep > 0) { + this.router.navigate(['../', previousStep], { relativeTo: this.route }); + } else { + this.router.navigate(['../', 'metadata'], { relativeTo: this.route }); + } + } + + goNext(): void { + const nextStep = this.step() + 1; + if (nextStep <= this.pages().length) { + this.router.navigate(['../', nextStep], { relativeTo: this.route }); + } else { + this.router.navigate(['../', 'review'], { relativeTo: this.route }); + } } } diff --git a/src/app/features/registries/components/drafts/drafts.component.ts b/src/app/features/registries/components/drafts/drafts.component.ts index fe7a21765..fffaa6c56 100644 --- a/src/app/features/registries/components/drafts/drafts.component.ts +++ b/src/app/features/registries/components/drafts/drafts.component.ts @@ -32,6 +32,7 @@ export class DraftsComponent { protected readonly pages = select(RegistriesSelectors.getPagesSchema); protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); protected stepsValidation = select(RegistriesSelectors.getStepsValidation); + protected readonly stepsData = select(RegistriesSelectors.getStepsData); private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, @@ -101,7 +102,7 @@ export class DraftsComponent { } effect(() => { const registrationSchemaId = this.draftRegistration()?.registrationSchemaId; - if (registrationSchemaId) { + if (registrationSchemaId && !this.pages().length) { this.actions .getSchemaBlocks(registrationSchemaId || '') .pipe( diff --git a/src/app/features/registries/mappers/registration.mapper.ts b/src/app/features/registries/mappers/registration.mapper.ts index 2d307b2a7..6111229fd 100644 --- a/src/app/features/registries/mappers/registration.mapper.ts +++ b/src/app/features/registries/mappers/registration.mapper.ts @@ -18,6 +18,7 @@ export class RegistrationMapper { : null, }, tags: response.attributes.tags || [], + stepsData: response.attributes.registration_responses || {}, }; } } diff --git a/src/app/features/registries/models/registration.model.ts b/src/app/features/registries/models/registration.model.ts index afe2de619..972d62566 100644 --- a/src/app/features/registries/models/registration.model.ts +++ b/src/app/features/registries/models/registration.model.ts @@ -10,4 +10,6 @@ export interface Registration { options: LicenseOptions | null; }; tags: string[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stepsData?: Record; } diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 21f417634..7988e5879 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -82,4 +82,9 @@ export class RegistriesSelectors { static getStepsValidation(state: RegistriesStateModel): Record { return state.stepsValidation; } + + @Selector([RegistriesState]) + static getStepsData(state: RegistriesStateModel) { + return state.draftRegistration.data?.stepsData || {}; + } } diff --git a/src/app/shared/components/stepper/stepper.component.html b/src/app/shared/components/stepper/stepper.component.html index 72651d17a..3b56e75cd 100644 --- a/src/app/shared/components/stepper/stepper.component.html +++ b/src/app/shared/components/stepper/stepper.component.html @@ -9,14 +9,14 @@ [class.current]="i === currentStep().index" (click)="onStepClick(step)" > - @if (i < currentStep().index) { - @if (step.invalid) { - - } @else { + @if (step.invalid && i !== currentStep().index) { + + } @else { + @if (i < currentStep().index) { + } @else { + {{ i + 1 }} } - } @else { - {{ i + 1 }} } @if (i < steps().length - 1) {