diff --git a/docs/core/models/form-field.model.md b/docs/core/models/form-field.model.md index ed98e18f8d..ab88e0ab5a 100644 --- a/docs/core/models/form-field.model.md +++ b/docs/core/models/form-field.model.md @@ -52,7 +52,8 @@ Contains the value and metadata for a field of a [`Form`](../../../lib/process-s | columns | [`ContainerColumnModel`](../../../lib/core/src/lib/form/components/widgets/core/container-column.model.ts)\[] | \[] | Column definitions for a container field | | rows | [`ContainerRowModel`](../../../lib/core/src/lib/form/components/widgets/core/container-row.model.ts)\[] | \[] | Row definitions for a repeatable section field | | emptyOption | [`FormFieldOption`](../../../lib/core/src/lib/form/components/widgets/core/form-field-option.ts) | | Dropdown menu item to use when no option is chosen | -| validationSummary | string | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) | +| validationSummary | [`ErrorMessageModel`](../../../lib/core/src/lib/form/components/widgets/core/error-message.model.ts) | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) | +| validationSummaryChanges$ | Observable<[`ErrorMessageModel`](../../../lib/core/src/lib/form/components/widgets/core/error-message.model.ts)> | | Replays the current validation summary to subscribers and emits the completed summary after each validation | ## Details diff --git a/lib/core/src/lib/form/components/widgets/amount/amount.widget.spec.ts b/lib/core/src/lib/form/components/widgets/amount/amount.widget.spec.ts index 8168f609a4..ac4ab4a1d4 100644 --- a/lib/core/src/lib/form/components/widgets/amount/amount.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/amount/amount.widget.spec.ts @@ -23,13 +23,14 @@ import { FormModel } from '../core/form.model'; import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { FormService } from '../../../services/form.service'; import { FormFieldEvent } from '../../../events/form-field.event'; import { TranslationService } from '../../../../translation/translation.service'; import { registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; import localeDeExtra from '@angular/common/locales/extra/de'; +import { TranslateService } from '@ngx-translate/core'; registerLocaleData(localeDe, 'de-DE', localeDeExtra); @@ -394,6 +395,76 @@ describe('AmountWidgetComponent - rendering', () => { expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER'); }); + describe('when validation runs without amount interaction', () => { + const validatorTranslations = { + FORM: { + FIELD: { + VALIDATOR: { + NOT_LESS_THAN: "Can't be less than {{ minValue }}", + NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}" + } + } + } + }; + let amountField: FormFieldModel; + let form: FormModel; + + beforeEach(async () => { + const translateService = TestBed.inject(TranslateService); + translateService.setTranslation('en', validatorTranslations); + await firstValueFrom(translateService.use('en')); + + form = new FormModel({ taskId: '' }, undefined, false, formService); + amountField = new FormFieldModel(form, { + id: 'amount-id', + type: FormFieldTypes.AMOUNT, + value: 1, + minValue: '10' + }); + form.fieldsCache = [amountField]; + amountField.validate(); + fixture.componentRef.setInput('field', amountField); + fixture.detectChanges(); + }); + + it('should render updated parameters after direct revalidation', async () => { + const formField = await testingUtils.formField.get(); + let errors = await formField.getTextErrors(); + expect(errors[0]).toContain("Can't be less than 10"); + + amountField.value = 10; + amountField.minValue = '1'; + amountField.maxValue = '5'; + amountField.validate(); + fixture.detectChanges(); + + errors = await formField.getTextErrors(); + expect(errors[0]).toContain("Can't be greater than 5"); + expect(errors[0]).not.toContain('{{'); + }); + + it('should render updated parameters after sibling field revalidation', async () => { + const siblingField = new FormFieldModel(form, { + id: 'sibling-id', + type: FormFieldTypes.TEXT, + value: 'before' + }); + form.fieldsCache = [amountField, siblingField]; + amountField.value = 10; + amountField.minValue = '1'; + amountField.maxValue = '5'; + + siblingField.value = 'after'; + form.onFormFieldChanged(siblingField); + fixture.detectChanges(); + + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + expect(errors[0]).toContain("Can't be greater than 5"); + expect(errors[0]).not.toContain('{{'); + }); + }); + describe('when form model has left labels', () => { it('should have left labels classes on leftLabels true', async () => { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { diff --git a/lib/core/src/lib/form/components/widgets/amount/amount.widget.ts b/lib/core/src/lib/form/components/widgets/amount/amount.widget.ts index 23ad1b867b..be96468ff7 100644 --- a/lib/core/src/lib/form/components/widgets/amount/amount.widget.ts +++ b/lib/core/src/lib/form/components/widgets/amount/amount.widget.ts @@ -25,6 +25,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; +import { getValidationSummaryTranslationParameters } from '../core/error-message.model'; import { WidgetComponent } from '../widget.component'; import { filter, isObservable, Observable } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -121,7 +122,9 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit { this.subscribeToFieldChanges(); this.setInitialValues(); this.initErrorStateMatcher(); - this.updateTranslateParameters(); + this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => { + this.translateParameters = getValidationSummaryTranslationParameters(validationSummary); + }); } } @@ -143,7 +146,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit { } } this.markAsTouched(); - this.updateTranslateParameters(); } amountWidgetOnFocus(): void { @@ -163,7 +165,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit { this.field.value = this.amountWidgetValue; super.onFieldChanged(this.field); this.markAsTouched(); - this.updateTranslateParameters(); } setInitialValues(): void { @@ -188,7 +189,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit { } else if (!this.isInputInFocus) { this.amountWidgetValue = ev.field.value; } - this.updateTranslateParameters(); }); } @@ -208,12 +208,4 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit { !!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched()) }; } - - private updateTranslateParameters(): void { - if (this.field?.validationSummary?.isActive()) { - this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj(); - } else { - this.translateParameters = {}; - } - } } diff --git a/lib/core/src/lib/form/components/widgets/core/error-message.model.ts b/lib/core/src/lib/form/components/widgets/core/error-message.model.ts index 19a7b4f183..c55236257c 100644 --- a/lib/core/src/lib/form/components/widgets/core/error-message.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/error-message.model.ts @@ -17,24 +17,36 @@ export class ErrorMessageModel { message: string = ''; - attributes: Map = null; + attributes: Map = new Map(); constructor(obj?: any) { this.message = obj?.message || ''; - this.attributes = obj?.attributes || new Map(); + + if (obj?.attributes) { + this.attributes = obj.attributes; + } } isActive(): boolean { return !!this.message; } - getAttributesAsJsonObj() { - const result = {}; + getAttributesAsJsonObj(): Record { + const result: Record = {}; if (this.attributes.size > 0) { this.attributes.forEach((value, key) => { result[key] = typeof value === 'string' ? value : JSON.stringify(value); }); } + return result; } } + +export const getValidationSummaryTranslationParameters = (validationSummary?: ErrorMessageModel): Record => { + if (validationSummary?.isActive()) { + return validationSummary.getAttributesAsJsonObj(); + } + + return {}; +}; diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts index 6649655f66..03bac3ae4b 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.spec.ts @@ -17,9 +17,10 @@ import { DateFnsUtils } from '../../../../common'; import { FormRulesEvent } from '../../../events/form-rules.event'; -import { firstValueFrom, map, Subject, take, timeout } from 'rxjs'; +import { firstValueFrom, map, skip, Subject, take, timeout } from 'rxjs'; +import { ErrorMessageModel, getValidationSummaryTranslationParameters } from './error-message.model'; import { FormFieldTypes } from './form-field-types'; -import { RequiredFieldValidator } from './form-field-validator'; +import { MinValueFieldValidator, RequiredFieldValidator } from './form-field-validator'; import { FormFieldModel } from './form-field.model'; import { FormModel } from './form.model'; @@ -1274,6 +1275,57 @@ describe('FormFieldModel', () => { }); }); + describe('validation summary changes', () => { + const createField = (): FormFieldModel => { + const form = new FormModel(); + form.fieldValidators = [new MinValueFieldValidator()]; + + return new FormFieldModel(form, { + id: 'number-field', + type: FormFieldTypes.NUMBER, + value: 1, + minValue: '10' + }); + }; + + it('should replay an inactive validation summary before the first validation', async () => { + const field = new FormFieldModel(new FormModel()); + + const validationSummary = await firstValueFrom(field.validationSummaryChanges$); + + expect(validationSummary).toEqual(jasmine.any(ErrorMessageModel)); + expect(validationSummary.isActive()).toBe(false); + }); + + it('should replay the completed validation summary when subscribing after validation', async () => { + const field = createField(); + field.validate(); + + const validationSummary = await firstValueFrom(field.validationSummaryChanges$); + + expect(validationSummary.message).toBe('FORM.FIELD.VALIDATOR.NOT_LESS_THAN'); + expect(validationSummary.attributes.get('minValue')).toBe('10'); + }); + + it('should emit completed summaries when validation state changes', async () => { + const field = createField(); + const invalidSummaryPromise = firstValueFrom(field.validationSummaryChanges$.pipe(skip(1))); + + field.validate(); + const invalidSummary = await invalidSummaryPromise; + + field.value = 10; + const validSummaryPromise = firstValueFrom(field.validationSummaryChanges$.pipe(skip(1))); + field.validate(); + const validSummary = await validSummaryPromise; + + expect(invalidSummary.message).toBe('FORM.FIELD.VALIDATOR.NOT_LESS_THAN'); + expect(invalidSummary.attributes.get('minValue')).toBe('10'); + expect(validSummary.isActive()).toBe(false); + expect(validSummary.attributes.size).toBe(0); + }); + }); + it('should fail validation for readOnly required display-external-property field with null value', () => { const form = new FormModel(); const field = new FormFieldModel(form, { @@ -2108,3 +2160,35 @@ describe('FormFieldTypes', () => { }); }); }); + +describe('ErrorMessageModel', () => { + it('should initialize empty attributes when attributes are omitted', () => { + const errorMessage = new ErrorMessageModel(); + + expect(errorMessage.attributes).toEqual(new Map()); + }); + + it('should retain provided attributes', () => { + const attributes = new Map([['minValue', '10']]); + + const errorMessage = new ErrorMessageModel({ attributes }); + + expect(errorMessage.attributes).toBe(attributes); + }); +}); + +describe('getValidationSummaryTranslationParameters', () => { + it('should return validation attributes when the summary is active', () => { + const validationSummary = new ErrorMessageModel({ + message: 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN', + attributes: new Map([['minValue', '10']]) + }); + + expect(getValidationSummaryTranslationParameters(validationSummary)).toEqual({ minValue: '10' }); + }); + + it('should return empty parameters when the summary is inactive or omitted', () => { + expect(getValidationSummaryTranslationParameters(new ErrorMessageModel())).toEqual({}); + expect(getValidationSummaryTranslationParameters()).toEqual({}); + }); +}); diff --git a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts index d38d692ab6..884e23eb41 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field.model.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field.model.ts @@ -29,6 +29,7 @@ import { VariableConfig } from './form-field-variable-options'; import { DataColumn } from '../../../../datatable/data/data-column.model'; import { DateFnsUtils } from '../../../../common'; import { isValid as isValidDate } from 'date-fns'; +import { Observable, ReplaySubject } from 'rxjs'; import { ContainerRowModel } from './container-row.model'; import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model'; import { formFieldRuleHandler } from './handlers/form-field-rule.handler'; @@ -38,6 +39,13 @@ export type FieldOptionType = 'rest' | 'manual' | 'variable'; export type FieldSelectionType = 'single' | 'multiple'; export type FieldAlignmentType = 'vertical' | 'horizontal'; +interface ValidationSummaryChangesState { + subject: ReplaySubject; + observable: Observable; +} + +const validationSummaryChangesByField = new WeakMap(); + const isJsonPrimitive = (value: unknown): value is null | string | number | boolean => value === null || ['string', 'number', 'boolean'].includes(typeof value); @@ -126,7 +134,21 @@ export class FormFieldModel extends FormWidgetModel { // util members emptyOption: FormFieldOption; - validationSummary: ErrorMessageModel; + validationSummary: ErrorMessageModel = new ErrorMessageModel(); + + get validationSummaryChanges$(): Observable { + const existingState = validationSummaryChangesByField.get(this); + if (existingState) { + return existingState.observable; + } + + const subject = new ReplaySubject(1); + const observable = subject.asObservable(); + validationSummaryChangesByField.set(this, { subject, observable }); + subject.next(this.validationSummary); + + return observable; + } get value(): any { return this._value; @@ -193,11 +215,13 @@ export class FormFieldModel extends FormWidgetModel { for (const validator of validators) { if (!validator.validate(this)) { this._isValid = false; + validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary); return this._isValid; } } this._isValid = true; + validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary); return this._isValid; } @@ -242,7 +266,6 @@ export class FormFieldModel extends FormWidgetModel { this.enableFractions = json.enableFractions; this.currency = json.currency; this.dateDisplayFormat = json.dateDisplayFormat || this.getDefaultDateFormat(json); - this.validationSummary = new ErrorMessageModel(); this.tooltip = json.tooltip || ''; this.selectionType = json.selectionType; this.alignmentType = json.alignmentType; diff --git a/lib/core/src/lib/form/components/widgets/decimal/decimal.component.spec.ts b/lib/core/src/lib/form/components/widgets/decimal/decimal.component.spec.ts index d4ab7fab85..a1fbbab018 100644 --- a/lib/core/src/lib/form/components/widgets/decimal/decimal.component.spec.ts +++ b/lib/core/src/lib/form/components/widgets/decimal/decimal.component.spec.ts @@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing'; import { FormService } from '../../../services/form.service'; import { FormFieldModel, FormFieldTypes, FormModel } from '../core'; import { DecimalWidgetComponent } from './decimal.component'; +import { TranslateService } from '@ngx-translate/core'; describe('DecimalComponent', () => { + const validatorTranslations = { + FORM: { + FIELD: { + VALIDATOR: { + NOT_LESS_THAN: "Can't be less than {{ minValue }}", + NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}", + INVALID_DECIMAL_PRECISION: 'Precision {{ precision }}' + } + } + } + }; let loader: HarnessLoader; let widget: DecimalWidgetComponent; let fixture: ComponentFixture; @@ -107,6 +119,62 @@ describe('DecimalComponent', () => { }); }); + describe('when validation runs without widget interaction', () => { + let field: FormFieldModel; + + beforeEach(() => { + const translateService = TestBed.inject(TranslateService); + translateService.use('en').subscribe(); + translateService.setTranslation('en', validatorTranslations); + field = new FormFieldModel(new FormModel({ taskId: '' }), { + id: 'decimal-id', + type: FormFieldTypes.DECIMAL, + value: 1, + minValue: 10 + }); + field.validate(); + field.form.showAllValidationErrors = true; + fixture.componentRef.setInput('field', field); + fixture.detectChanges(); + }); + + it('should render the minimum value in the message when initial validation fails', async () => { + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain("Can't be less than 10"); + }); + + it('should render the updated maximum value when programmatic revalidation fails', async () => { + field.value = 10; + field.minValue = '1'; + field.maxValue = '5'; + field.validate(); + fixture.detectChanges(); + + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain("Can't be greater than 5"); + }); + + it('should render decimal precision when programmatic revalidation fails', async () => { + field.value = 1.234; + field.minValue = '1'; + field.precision = 2; + field.validate(); + fixture.detectChanges(); + + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('Precision 2'); + }); + }); + describe('when form model has left labels', () => { it('should have left labels classes on leftLabels true', async () => { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { diff --git a/lib/core/src/lib/form/components/widgets/decimal/decimal.component.ts b/lib/core/src/lib/form/components/widgets/decimal/decimal.component.ts index a88390caf7..a25bbb1b36 100644 --- a/lib/core/src/lib/form/components/widgets/decimal/decimal.component.ts +++ b/lib/core/src/lib/form/components/widgets/decimal/decimal.component.ts @@ -16,13 +16,15 @@ */ import { NgIf } from '@angular/common'; -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms'; import { ErrorStateMatcher } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; +import { getValidationSummaryTranslationParameters } from '../core/error-message.model'; import { WidgetComponent } from '../widget.component'; @Component({ @@ -46,19 +48,21 @@ import { WidgetComponent } from '../widget.component'; export class DecimalWidgetComponent extends WidgetComponent implements OnInit { errorStateMatcher: ErrorStateMatcher; translateParameters: Record = {}; + private readonly destroyRef = inject(DestroyRef); ngOnInit(): void { this.initErrorStateMatcher(); + this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => { + this.translateParameters = getValidationSummaryTranslationParameters(validationSummary); + }); } onBlur(): void { this.markAsTouched(); - this.updateTranslateParameters(); } onDecimalFieldChanged(): void { this.onFieldChanged(this.field); - this.updateTranslateParameters(); } private initErrorStateMatcher(): void { @@ -67,12 +71,4 @@ export class DecimalWidgetComponent extends WidgetComponent implements OnInit { !this.field.isValid && this.isTouched() }; } - - private updateTranslateParameters(): void { - if (this.field.validationSummary?.isActive()) { - this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj(); - } else { - this.translateParameters = {}; - } - } } diff --git a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.spec.ts b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.spec.ts index b2398bf024..96ed448e98 100644 --- a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.spec.ts @@ -25,8 +25,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { of, Subject } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; describe('MultilineTextWidgetComponentComponent', () => { + const validatorTranslations = { + FORM: { + FIELD: { + VALIDATOR: { + AT_LEAST_LONG: 'Minimum {{ minLength }}', + NO_LONGER_THAN: 'Maximum {{ maxLength }}' + } + } + } + }; let loader: HarnessLoader; let widget: MultilineTextWidgetComponentComponent; let fixture: ComponentFixture; @@ -109,6 +120,48 @@ describe('MultilineTextWidgetComponentComponent', () => { }); }); + describe('when validation runs without widget interaction', () => { + let field: FormFieldModel; + + beforeEach(() => { + const translateService = TestBed.inject(TranslateService); + translateService.use('en').subscribe(); + translateService.setTranslation('en', validatorTranslations); + field = new FormFieldModel(new FormModel({ taskId: '' }), { + id: 'multiline-text-id', + type: FormFieldTypes.MULTILINE_TEXT, + value: 'text', + minLength: 10 + }); + field.validate(); + field.form.showAllValidationErrors = true; + fixture.componentRef.setInput('field', field); + fixture.detectChanges(); + }); + + it('should render the minimum length in the message when initial validation fails', async () => { + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('Minimum 10'); + }); + + it('should render the updated maximum length when programmatic revalidation fails', async () => { + field.value = 'too long'; + field.minLength = 1; + field.maxLength = 5; + field.validate(); + fixture.detectChanges(); + + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('Maximum 5'); + }); + }); + describe('when is required', () => { beforeEach(() => { widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { diff --git a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.ts b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.ts index ecfad39ae3..53fd4c37aa 100644 --- a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.ts +++ b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.ts @@ -27,6 +27,7 @@ import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; import { isObservable } from 'rxjs'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; +import { getValidationSummaryTranslationParameters } from '../core/error-message.model'; import { WidgetComponent } from '../widget.component'; @Component({ @@ -56,6 +57,9 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple ngOnInit(): void { this.initErrorStateMatcher(); + this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => { + this.translateParameters = getValidationSummaryTranslationParameters(validationSummary); + }); if (this.enableCustomMessage != null) { if (isObservable(this.enableCustomMessage)) { this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { @@ -73,12 +77,10 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple onBlur(): void { this.markAsTouched(); - this.updateTranslateParameters(); } onMultilineTextFieldChanged(): void { this.onFieldChanged(this.field); - this.updateTranslateParameters(); } private initErrorStateMatcher(): void { @@ -87,12 +89,4 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple !this.field.isValid && this.isTouched() }; } - - private updateTranslateParameters(): void { - if (this.field.validationSummary?.isActive()) { - this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj(); - } else { - this.translateParameters = {}; - } - } } diff --git a/lib/core/src/lib/form/components/widgets/number/number.widget.spec.ts b/lib/core/src/lib/form/components/widgets/number/number.widget.spec.ts index 3ef03a164e..e853c0c43b 100644 --- a/lib/core/src/lib/form/components/widgets/number/number.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/number/number.widget.spec.ts @@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing'; import { FormFieldModel, FormFieldTypes, FormModel } from '../core'; import { NumberWidgetComponent } from './number.widget'; import { DecimalNumberPipe } from '../../../../pipes'; +import { TranslateService } from '@ngx-translate/core'; describe('NumberWidgetComponent', () => { + const validatorTranslations = { + FORM: { + FIELD: { + VALIDATOR: { + NOT_LESS_THAN: "Can't be less than {{ minValue }}", + NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}", + NO_LONGER_THAN: 'Maximum length {{ maxLength }}' + } + } + } + }; let loader: HarnessLoader; let widget: NumberWidgetComponent; let fixture: ComponentFixture; @@ -175,6 +187,60 @@ describe('NumberWidgetComponent', () => { }); }); + describe('when validation runs without widget interaction', () => { + let field: FormFieldModel; + + beforeEach(() => { + const translateService = TestBed.inject(TranslateService); + translateService.use('en').subscribe(); + translateService.setTranslation('en', validatorTranslations); + field = new FormFieldModel(new FormModel({ taskId: '' }), { + id: 'number-id', + type: FormFieldTypes.NUMBER, + value: 1, + minValue: 10 + }); + field.validate(); + fixture.componentRef.setInput('field', field); + fixture.detectChanges(); + }); + + it('should render the minimum value in the message when initial validation fails', async () => { + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain("Can't be less than 10"); + }); + + it('should render the updated maximum value when programmatic revalidation fails', async () => { + field.value = 10; + field.minValue = '1'; + field.maxValue = '5'; + field.validate(); + fixture.detectChanges(); + + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain("Can't be greater than 5"); + }); + + it('should render the maximum length when programmatic revalidation fails', async () => { + field.value = 12345678901; + field.minValue = '1'; + field.validate(); + fixture.detectChanges(); + + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('Maximum length 10'); + }); + }); + describe('when form model has left labels', () => { it('should have left labels classes on leftLabels true', async () => { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { diff --git a/lib/core/src/lib/form/components/widgets/number/number.widget.ts b/lib/core/src/lib/form/components/widgets/number/number.widget.ts index 113219a0ef..e7f378ca1d 100644 --- a/lib/core/src/lib/form/components/widgets/number/number.widget.ts +++ b/lib/core/src/lib/form/components/widgets/number/number.widget.ts @@ -18,7 +18,8 @@ /* eslint-disable @angular-eslint/component-selector */ import { NgIf } from '@angular/common'; -import { Component, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms'; import { ErrorStateMatcher } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -26,6 +27,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; import { DecimalNumberPipe } from '../../../../pipes'; +import { getValidationSummaryTranslationParameters } from '../core/error-message.model'; import { WidgetComponent } from '../widget.component'; @Component({ @@ -53,6 +55,7 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit { translateParameters: Record = {}; private readonly decimalNumberPipe = inject(DecimalNumberPipe); + private readonly destroyRef = inject(DestroyRef); ngOnInit() { if (this.field.readOnly) { @@ -61,11 +64,13 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit { this.displayValue = this.field.value; } this.initErrorStateMatcher(); + this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => { + this.translateParameters = getValidationSummaryTranslationParameters(validationSummary); + }); } onBlur(): void { this.markAsTouched(); - this.updateTranslateParameters(); } protected onNumberChange(value: string) { @@ -74,7 +79,6 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit { } this.onFieldChanged(this.field); - this.updateTranslateParameters(); } private initErrorStateMatcher(): void { @@ -83,12 +87,4 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit { !!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched()) }; } - - private updateTranslateParameters(): void { - if (this.field.validationSummary?.isActive()) { - this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj(); - } else { - this.translateParameters = {}; - } - } } diff --git a/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts b/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts index 48e576800a..816ad455a7 100644 --- a/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/text/text.widget.spec.ts @@ -27,9 +27,20 @@ import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token'; import { of, Subject } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; describe('TextWidgetComponent', () => { const form = new FormModel({ taskId: 'fake-task-id' }); + const validatorTranslations = { + FORM: { + FIELD: { + VALIDATOR: { + AT_LEAST_LONG: 'Minimum {{ minLength }}', + NO_LONGER_THAN: 'Maximum {{ maxLength }}' + } + } + } + }; let loader: HarnessLoader; let widget: TextWidgetComponent; @@ -64,6 +75,47 @@ describe('TextWidgetComponent', () => { }); }); + describe('when validation runs without widget interaction', () => { + let field: FormFieldModel; + + beforeEach(() => { + const translateService = TestBed.inject(TranslateService); + translateService.use('en').subscribe(); + translateService.setTranslation('en', validatorTranslations); + field = new FormFieldModel(form, { + id: 'text-id', + type: FormFieldTypes.TEXT, + value: 'text', + minLength: 10 + }); + field.validate(); + fixture.componentRef.setInput('field', field); + fixture.detectChanges(); + }); + + it('should render the minimum length in the message when initial validation fails', async () => { + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('Minimum 10'); + }); + + it('should render the updated maximum length when programmatic revalidation fails', async () => { + field.value = 'too long'; + field.minLength = 1; + field.maxLength = 5; + field.validate(); + fixture.detectChanges(); + + const formField = await testingUtils.formField.get(); + const errors = await formField.getTextErrors(); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('Maximum 5'); + }); + }); + describe('when template is ready', () => { describe('and no mask is configured on text element', () => { it('should raise ngModelChange event', async () => { diff --git a/lib/core/src/lib/form/components/widgets/text/text.widget.ts b/lib/core/src/lib/form/components/widgets/text/text.widget.ts index 665b315fd7..161afcaaef 100644 --- a/lib/core/src/lib/form/components/widgets/text/text.widget.ts +++ b/lib/core/src/lib/form/components/widgets/text/text.widget.ts @@ -19,6 +19,7 @@ import { NgIf, NgTemplateOutlet } from '@angular/common'; import { Component, Directive, inject, InjectionToken, Input, TemplateRef, ViewEncapsulation } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms'; import { ErrorStateMatcher } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -26,7 +27,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; import { WidgetComponent } from '../widget.component'; -import { ErrorMessageModel } from '../core/error-message.model'; +import { ErrorMessageModel, getValidationSummaryTranslationParameters } from '../core/error-message.model'; import { FormattableTextWidgetComponent } from '../core/formattable-text.widget'; import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator'; import { InputMaskDirective } from './text-mask.component'; @@ -97,6 +98,9 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent { this.isMaskReversed = this.field.params['inputMaskReversed'] ? this.field.params['inputMaskReversed'] : false; } this.initErrorStateMatcher(); + this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => { + this.translateParameters = getValidationSummaryTranslationParameters(validationSummary); + }); } onPaste(event: ClipboardEvent): void { @@ -125,12 +129,10 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent { onBlur(): void { this.markAsTouched(); - this.updateTranslateParameters(); } onTextFieldChanged(): void { this.onFieldChanged(this.field); - this.updateTranslateParameters(); } private initErrorStateMatcher(): void { @@ -143,14 +145,6 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent { }; } - private updateTranslateParameters(): void { - if (this.field.validationSummary?.isActive()) { - this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj(); - } else { - this.translateParameters = {}; - } - } - private getLengthAfterPaste(input: HTMLInputElement, pastedValue: string): number { const value = input.value ?? ''; const selectionStart = input.selectionStart ?? value.length;