AAE-50099 Fix parameterized validation message interpolation (#12170)

This commit is contained in:
Alex Molodyh
2026-08-18 19:37:03 -07:00
committed by GitHub
parent dfbe1adbd5
commit f907f8ef10
14 changed files with 467 additions and 65 deletions
+2 -1
View File
@@ -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 | | 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 | | 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 | | 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 ## Details
@@ -23,13 +23,14 @@ import { FormModel } from '../core/form.model';
import { HarnessLoader } from '@angular/cdk/testing'; import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { FormService } from '../../../services/form.service'; import { FormService } from '../../../services/form.service';
import { FormFieldEvent } from '../../../events/form-field.event'; import { FormFieldEvent } from '../../../events/form-field.event';
import { TranslationService } from '../../../../translation/translation.service'; import { TranslationService } from '../../../../translation/translation.service';
import { registerLocaleData } from '@angular/common'; import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de'; import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de'; import localeDeExtra from '@angular/common/locales/extra/de';
import { TranslateService } from '@ngx-translate/core';
registerLocaleData(localeDe, 'de-DE', localeDeExtra); registerLocaleData(localeDe, 'de-DE', localeDeExtra);
@@ -394,6 +395,76 @@ describe('AmountWidgetComponent - rendering', () => {
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER'); 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: '<id>' }, 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', () => { describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => { it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -25,6 +25,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component'; import { WidgetComponent } from '../widget.component';
import { filter, isObservable, Observable } from 'rxjs'; import { filter, isObservable, Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -121,7 +122,9 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.subscribeToFieldChanges(); this.subscribeToFieldChanges();
this.setInitialValues(); this.setInitialValues();
this.initErrorStateMatcher(); 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.markAsTouched();
this.updateTranslateParameters();
} }
amountWidgetOnFocus(): void { amountWidgetOnFocus(): void {
@@ -163,7 +165,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.field.value = this.amountWidgetValue; this.field.value = this.amountWidgetValue;
super.onFieldChanged(this.field); super.onFieldChanged(this.field);
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
setInitialValues(): void { setInitialValues(): void {
@@ -188,7 +189,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
} else if (!this.isInputInFocus) { } else if (!this.isInputInFocus) {
this.amountWidgetValue = ev.field.value; 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()) !!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 = {};
}
}
} }
@@ -17,24 +17,36 @@
export class ErrorMessageModel { export class ErrorMessageModel {
message: string = ''; message: string = '';
attributes: Map<string, string> = null; attributes: Map<string, string> = new Map();
constructor(obj?: any) { constructor(obj?: any) {
this.message = obj?.message || ''; this.message = obj?.message || '';
this.attributes = obj?.attributes || new Map();
if (obj?.attributes) {
this.attributes = obj.attributes;
}
} }
isActive(): boolean { isActive(): boolean {
return !!this.message; return !!this.message;
} }
getAttributesAsJsonObj() { getAttributesAsJsonObj(): Record<string, string> {
const result = {}; const result: Record<string, string> = {};
if (this.attributes.size > 0) { if (this.attributes.size > 0) {
this.attributes.forEach((value, key) => { this.attributes.forEach((value, key) => {
result[key] = typeof value === 'string' ? value : JSON.stringify(value); result[key] = typeof value === 'string' ? value : JSON.stringify(value);
}); });
} }
return result; return result;
} }
} }
export const getValidationSummaryTranslationParameters = (validationSummary?: ErrorMessageModel): Record<string, string> => {
if (validationSummary?.isActive()) {
return validationSummary.getAttributesAsJsonObj();
}
return {};
};
@@ -17,9 +17,10 @@
import { DateFnsUtils } from '../../../../common'; import { DateFnsUtils } from '../../../../common';
import { FormRulesEvent } from '../../../events/form-rules.event'; 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 { 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 { FormFieldModel } from './form-field.model';
import { FormModel } from './form.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', () => { it('should fail validation for readOnly required display-external-property field with null value', () => {
const form = new FormModel(); const form = new FormModel();
const field = new FormFieldModel(form, { 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({});
});
});
@@ -29,6 +29,7 @@ import { VariableConfig } from './form-field-variable-options';
import { DataColumn } from '../../../../datatable/data/data-column.model'; import { DataColumn } from '../../../../datatable/data/data-column.model';
import { DateFnsUtils } from '../../../../common'; import { DateFnsUtils } from '../../../../common';
import { isValid as isValidDate } from 'date-fns'; import { isValid as isValidDate } from 'date-fns';
import { Observable, ReplaySubject } from 'rxjs';
import { ContainerRowModel } from './container-row.model'; import { ContainerRowModel } from './container-row.model';
import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model'; import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model';
import { formFieldRuleHandler } from './handlers/form-field-rule.handler'; 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 FieldSelectionType = 'single' | 'multiple';
export type FieldAlignmentType = 'vertical' | 'horizontal'; export type FieldAlignmentType = 'vertical' | 'horizontal';
interface ValidationSummaryChangesState {
subject: ReplaySubject<ErrorMessageModel>;
observable: Observable<ErrorMessageModel>;
}
const validationSummaryChangesByField = new WeakMap<FormFieldModel, ValidationSummaryChangesState>();
const isJsonPrimitive = (value: unknown): value is null | string | number | boolean => const isJsonPrimitive = (value: unknown): value is null | string | number | boolean =>
value === null || ['string', 'number', 'boolean'].includes(typeof value); value === null || ['string', 'number', 'boolean'].includes(typeof value);
@@ -126,7 +134,21 @@ export class FormFieldModel extends FormWidgetModel {
// util members // util members
emptyOption: FormFieldOption; emptyOption: FormFieldOption;
validationSummary: ErrorMessageModel; validationSummary: ErrorMessageModel = new ErrorMessageModel();
get validationSummaryChanges$(): Observable<ErrorMessageModel> {
const existingState = validationSummaryChangesByField.get(this);
if (existingState) {
return existingState.observable;
}
const subject = new ReplaySubject<ErrorMessageModel>(1);
const observable = subject.asObservable();
validationSummaryChangesByField.set(this, { subject, observable });
subject.next(this.validationSummary);
return observable;
}
get value(): any { get value(): any {
return this._value; return this._value;
@@ -193,11 +215,13 @@ export class FormFieldModel extends FormWidgetModel {
for (const validator of validators) { for (const validator of validators) {
if (!validator.validate(this)) { if (!validator.validate(this)) {
this._isValid = false; this._isValid = false;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid; return this._isValid;
} }
} }
this._isValid = true; this._isValid = true;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid; return this._isValid;
} }
@@ -242,7 +266,6 @@ export class FormFieldModel extends FormWidgetModel {
this.enableFractions = json.enableFractions; this.enableFractions = json.enableFractions;
this.currency = json.currency; this.currency = json.currency;
this.dateDisplayFormat = json.dateDisplayFormat || this.getDefaultDateFormat(json); this.dateDisplayFormat = json.dateDisplayFormat || this.getDefaultDateFormat(json);
this.validationSummary = new ErrorMessageModel();
this.tooltip = json.tooltip || ''; this.tooltip = json.tooltip || '';
this.selectionType = json.selectionType; this.selectionType = json.selectionType;
this.alignmentType = json.alignmentType; this.alignmentType = json.alignmentType;
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormService } from '../../../services/form.service'; import { FormService } from '../../../services/form.service';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core'; import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { DecimalWidgetComponent } from './decimal.component'; import { DecimalWidgetComponent } from './decimal.component';
import { TranslateService } from '@ngx-translate/core';
describe('DecimalComponent', () => { 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 loader: HarnessLoader;
let widget: DecimalWidgetComponent; let widget: DecimalWidgetComponent;
let fixture: ComponentFixture<DecimalWidgetComponent>; let fixture: ComponentFixture<DecimalWidgetComponent>;
@@ -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>' }), {
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', () => { describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => { it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -16,13 +16,15 @@
*/ */
import { NgIf } from '@angular/common'; 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 { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core'; import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component'; import { WidgetComponent } from '../widget.component';
@Component({ @Component({
@@ -46,19 +48,21 @@ import { WidgetComponent } from '../widget.component';
export class DecimalWidgetComponent extends WidgetComponent implements OnInit { export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
errorStateMatcher: ErrorStateMatcher; errorStateMatcher: ErrorStateMatcher;
translateParameters: Record<string, string> = {}; translateParameters: Record<string, string> = {};
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void { ngOnInit(): void {
this.initErrorStateMatcher(); this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
} }
onBlur(): void { onBlur(): void {
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
onDecimalFieldChanged(): void { onDecimalFieldChanged(): void {
this.onFieldChanged(this.field); this.onFieldChanged(this.field);
this.updateTranslateParameters();
} }
private initErrorStateMatcher(): void { private initErrorStateMatcher(): void {
@@ -67,12 +71,4 @@ export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
!this.field.isValid && this.isTouched() !this.field.isValid && this.isTouched()
}; };
} }
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
} }
@@ -25,8 +25,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { of, Subject } from 'rxjs'; import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('MultilineTextWidgetComponentComponent', () => { describe('MultilineTextWidgetComponentComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
AT_LEAST_LONG: 'Minimum {{ minLength }}',
NO_LONGER_THAN: 'Maximum {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader; let loader: HarnessLoader;
let widget: MultilineTextWidgetComponentComponent; let widget: MultilineTextWidgetComponentComponent;
let fixture: ComponentFixture<MultilineTextWidgetComponentComponent>; let fixture: ComponentFixture<MultilineTextWidgetComponentComponent>;
@@ -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>' }), {
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', () => { describe('when is required', () => {
beforeEach(() => { beforeEach(() => {
widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), { widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
@@ -27,6 +27,7 @@ import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { isObservable } from 'rxjs'; import { isObservable } from 'rxjs';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component'; import { WidgetComponent } from '../widget.component';
@Component({ @Component({
@@ -56,6 +57,9 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
ngOnInit(): void { ngOnInit(): void {
this.initErrorStateMatcher(); this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
if (this.enableCustomMessage != null) { if (this.enableCustomMessage != null) {
if (isObservable(this.enableCustomMessage)) { if (isObservable(this.enableCustomMessage)) {
this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => {
@@ -73,12 +77,10 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
onBlur(): void { onBlur(): void {
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
onMultilineTextFieldChanged(): void { onMultilineTextFieldChanged(): void {
this.onFieldChanged(this.field); this.onFieldChanged(this.field);
this.updateTranslateParameters();
} }
private initErrorStateMatcher(): void { private initErrorStateMatcher(): void {
@@ -87,12 +89,4 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
!this.field.isValid && this.isTouched() !this.field.isValid && this.isTouched()
}; };
} }
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
} }
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core'; import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { NumberWidgetComponent } from './number.widget'; import { NumberWidgetComponent } from './number.widget';
import { DecimalNumberPipe } from '../../../../pipes'; import { DecimalNumberPipe } from '../../../../pipes';
import { TranslateService } from '@ngx-translate/core';
describe('NumberWidgetComponent', () => { 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 loader: HarnessLoader;
let widget: NumberWidgetComponent; let widget: NumberWidgetComponent;
let fixture: ComponentFixture<NumberWidgetComponent>; let fixture: ComponentFixture<NumberWidgetComponent>;
@@ -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>' }), {
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', () => { describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => { it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -18,7 +18,8 @@
/* eslint-disable @angular-eslint/component-selector */ /* eslint-disable @angular-eslint/component-selector */
import { NgIf } from '@angular/common'; 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 { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core'; import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,6 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { DecimalNumberPipe } from '../../../../pipes'; import { DecimalNumberPipe } from '../../../../pipes';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component'; import { WidgetComponent } from '../widget.component';
@Component({ @Component({
@@ -53,6 +55,7 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
translateParameters: Record<string, string> = {}; translateParameters: Record<string, string> = {};
private readonly decimalNumberPipe = inject(DecimalNumberPipe); private readonly decimalNumberPipe = inject(DecimalNumberPipe);
private readonly destroyRef = inject(DestroyRef);
ngOnInit() { ngOnInit() {
if (this.field.readOnly) { if (this.field.readOnly) {
@@ -61,11 +64,13 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
this.displayValue = this.field.value; this.displayValue = this.field.value;
} }
this.initErrorStateMatcher(); this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
} }
onBlur(): void { onBlur(): void {
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
protected onNumberChange(value: string) { protected onNumberChange(value: string) {
@@ -74,7 +79,6 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
} }
this.onFieldChanged(this.field); this.onFieldChanged(this.field);
this.updateTranslateParameters();
} }
private initErrorStateMatcher(): void { private initErrorStateMatcher(): void {
@@ -83,12 +87,4 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched()) !!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 = {};
}
}
} }
@@ -27,9 +27,20 @@ import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token'; import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token';
import { of, Subject } from 'rxjs'; import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('TextWidgetComponent', () => { describe('TextWidgetComponent', () => {
const form = new FormModel({ taskId: 'fake-task-id' }); 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 loader: HarnessLoader;
let widget: TextWidgetComponent; 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('when template is ready', () => {
describe('and no mask is configured on text element', () => { describe('and no mask is configured on text element', () => {
it('should raise ngModelChange event', async () => { it('should raise ngModelChange event', async () => {
@@ -19,6 +19,7 @@
import { NgIf, NgTemplateOutlet } from '@angular/common'; import { NgIf, NgTemplateOutlet } from '@angular/common';
import { Component, Directive, inject, InjectionToken, Input, TemplateRef, ViewEncapsulation } from '@angular/core'; 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 { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core'; import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,7 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { WidgetComponent } from '../widget.component'; 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 { FormattableTextWidgetComponent } from '../core/formattable-text.widget';
import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator'; import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator';
import { InputMaskDirective } from './text-mask.component'; 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.isMaskReversed = this.field.params['inputMaskReversed'] ? this.field.params['inputMaskReversed'] : false;
} }
this.initErrorStateMatcher(); this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
} }
onPaste(event: ClipboardEvent): void { onPaste(event: ClipboardEvent): void {
@@ -125,12 +129,10 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
onBlur(): void { onBlur(): void {
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
onTextFieldChanged(): void { onTextFieldChanged(): void {
this.onFieldChanged(this.field); this.onFieldChanged(this.field);
this.updateTranslateParameters();
} }
private initErrorStateMatcher(): void { 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 { private getLengthAfterPaste(input: HTMLInputElement, pastedValue: string): number {
const value = input.value ?? ''; const value = input.value ?? '';
const selectionStart = input.selectionStart ?? value.length; const selectionStart = input.selectionStart ?? value.length;