From 69a9dc138607f1257633b2d0967bd3db402ea651 Mon Sep 17 00:00:00 2001 From: Darren Thornton <6361057+dthornton-hyl@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:23:51 -0500 Subject: [PATCH] AAE-44002 Allow user input for date and datetime fields (#11791) --- .../date-time/date-time.widget.spec.ts | 107 +++++++++++- .../widgets/date-time/date-time.widget.ts | 17 +- .../widgets/date/date.widget.spec.ts | 154 +++++++++++++++++- .../components/widgets/date/date.widget.ts | 18 +- lib/core/src/lib/i18n/en.json | 1 + .../widgets/date/date-cloud.widget.spec.ts | 131 +++++++++++++++ .../widgets/date/date-cloud.widget.ts | 17 +- 7 files changed, 424 insertions(+), 21 deletions(-) diff --git a/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.spec.ts b/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.spec.ts index df98ec202e..b2c199ddbe 100644 --- a/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.spec.ts @@ -196,25 +196,27 @@ describe('DateTimeWidgetComponent', () => { expect(widget.datetimeInputControl.invalid).toBeTrue(); expect(field.isValid).toBeFalse(); - expect(field.validationSummary.message).toBe('D-M-YYYY hh:mm A'); + expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT'); + expect(field.validationSummary.attributes.get('format')).toBe('D-M-YYYY hh:mm A'); }); - // eslint-disable-next-line - xit('should process direct keyboard input', async () => { + it('should process direct keyboard input', async () => { const field = new FormFieldModel(form, { id: 'date-field-id', name: 'date-name', - value: '9999-09-12T09:00:00.000Z', type: FormFieldTypes.DATETIME }); widget.field = field; - fixture.whenStable(); + fixture.detectChanges(); await fixture.whenStable(); - await testingUtils.fillMatInput('9999-09-12T09:10:00.000Z'); + await testingUtils.fillMatInput('12-09-9999 10:30 AM'); - expect(field.value).toEqual(new Date('9999-09-12T09:10:00.000Z')); + expect(field.value).toEqual(jasmine.any(Date)); + expect(field.value.getFullYear()).toBe(9999); + expect(field.value.getMonth()).toBe(8); + expect(field.value.getDate()).toBe(12); expect(field.isValid).toBeTrue(); }); @@ -235,7 +237,8 @@ describe('DateTimeWidgetComponent', () => { expect(widget.datetimeInputControl.invalid).toBeTrue(); expect(field.value).toBe(null); expect(field.isValid).toBeFalse(); - expect(field.validationSummary.message).toBe('D-M-YYYY hh:mm A'); + expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT'); + expect(field.validationSummary.attributes.get('format')).toBe('D-M-YYYY hh:mm A'); }); it('should allow empty dates when not required', async () => { @@ -259,6 +262,94 @@ describe('DateTimeWidgetComponent', () => { expect(field.isValid).toBeTrue(); }); + it('should process keyboard input with custom dateDisplayFormat', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATETIME, + dateDisplayFormat: 'MM-DD-YYYY hh:mm A' + }); + + widget.field = field; + + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('03-25-2025 02:30 PM'); + + expect(field.value).toEqual(jasmine.any(Date)); + expect(field.value.getFullYear()).toBe(2025); + expect(field.value.getMonth()).toBe(2); + expect(field.value.getDate()).toBe(25); + expect(field.isValid).toBeTrue(); + }); + + it('should show validation error when datetime is typed in wrong format', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATETIME, + dateDisplayFormat: 'MM-DD-YYYY hh:mm A' + }); + + widget.field = field; + + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('03/25/2025 02:30 PM'); + + expect(widget.datetimeInputControl.invalid).toBeTrue(); + expect(field.isValid).toBeFalse(); + expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT'); + expect(field.validationSummary.attributes.get('format')).toBe('MM-DD-YYYY hh:mm A'); + }); + + it('should transition from invalid to valid when clearing input on non-required field', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATETIME + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('invalid-text'); + + expect(widget.datetimeInputControl.invalid).toBeTrue(); + expect(field.isValid).toBeFalse(); + + await testingUtils.fillMatInput(''); + + expect(widget.datetimeInputControl.valid).toBeTrue(); + expect(field.isValid).toBeTrue(); + expect(field.validationSummary.message).toBe(''); + }); + + describe('addValidators idempotency', () => { + it('should not stack required validators on repeated updateReactiveFormControl calls', () => { + widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { + type: FormFieldTypes.DATETIME, + required: true + }); + widget.field.isVisible = true; + + fixture.detectChanges(); + + widget.updateReactiveFormControl(); + widget.updateReactiveFormControl(); + widget.updateReactiveFormControl(); + + widget.datetimeInputControl.setValue(new Date('2025-06-15T10:30:00.000Z')); + fixture.detectChanges(); + + expect(widget.field.isValid).toBeTrue(); + expect(widget.datetimeInputControl.valid).toBeTrue(); + }); + }); + describe('when tooltip is set', () => { beforeEach(() => { widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { diff --git a/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.ts b/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.ts index 07dabf5a6f..67a6ff90ad 100644 --- a/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.ts +++ b/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.ts @@ -78,11 +78,17 @@ export class DateTimeWidgetComponent extends WidgetComponent implements OnInit, } private setFormControlValue(): void { - this.datetimeInputControl.setValue(this.field.value, { emitEvent: false }); + if (this.field.value !== this.datetimeInputControl.value) { + this.datetimeInputControl.setValue(this.field.value, { emitEvent: false }); + } } private updateFormControlState(): void { - this.datetimeInputControl.setValidators(this.isRequired() && this.field?.isVisible ? [Validators.required] : []); + if (this.isRequired() && this.field?.isVisible) { + this.datetimeInputControl.addValidators(Validators.required); + } else { + this.datetimeInputControl.removeValidators(Validators.required); + } this.field?.readOnly || this.readOnly ? this.datetimeInputControl.disable({ emitEvent: false }) : this.datetimeInputControl.enable({ emitEvent: false }); @@ -115,9 +121,12 @@ export class DateTimeWidgetComponent extends WidgetComponent implements OnInit, private handleErrors(errors: ValidationErrors): void { const errorAttributes = new Map(); switch (true) { - case !!errors.matDatepickerParse: - this.updateValidationSummary(this.field.dateDisplayFormat || this.field.defaultDateTimeFormat); + case !!errors.matDatepickerParse: { + const format = this.field.dateDisplayFormat || this.field.defaultDateTimeFormat; + errorAttributes.set('format', format); + this.updateValidationSummary('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT', errorAttributes); break; + } case !!errors.required: this.updateValidationSummary('FORM.FIELD.REQUIRED'); break; diff --git a/lib/core/src/lib/form/components/widgets/date/date.widget.spec.ts b/lib/core/src/lib/form/components/widgets/date/date.widget.spec.ts index a2ce4f4fa1..e7b4db8456 100644 --- a/lib/core/src/lib/form/components/widgets/date/date.widget.spec.ts +++ b/lib/core/src/lib/form/components/widgets/date/date.widget.spec.ts @@ -22,12 +22,15 @@ import { FormFieldModel, FormFieldTypes, FormModel } from '../core'; import { DateWidgetComponent } from './date.widget'; import { DEFAULT_DATE_FORMAT } from '../../../../common'; import { isEqual } from 'date-fns'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; describe('DateWidgetComponent', () => { let widget: DateWidgetComponent; let fixture: ComponentFixture; let adapter: DateAdapter; let form: FormModel; + let loader: HarnessLoader; let testingUtils: UnitTestingUtils; beforeEach(() => { @@ -39,7 +42,8 @@ describe('DateWidgetComponent', () => { fixture = TestBed.createComponent(DateWidgetComponent); adapter = fixture.debugElement.injector.get(DateAdapter); widget = fixture.componentInstance; - testingUtils = new UnitTestingUtils(fixture.debugElement); + loader = TestbedHarnessEnvironment.loader(fixture); + testingUtils = new UnitTestingUtils(fixture.debugElement, loader); }); describe('event tracking', () => { @@ -374,4 +378,152 @@ describe('DateWidgetComponent', () => { expect(widget.dateInputControl.touched).toBe(false); }); }); + + describe('keyboard input', () => { + it('should process typed date in default format', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('25-03-2025'); + + expect(field.value).toEqual(jasmine.any(Date)); + expect(field.value.getFullYear()).toBe(2025); + expect(field.value.getMonth()).toBe(2); + expect(field.value.getDate()).toBe(25); + expect(field.isValid).toBeTrue(); + }); + + it('should process typed date in custom dateDisplayFormat', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE, + dateDisplayFormat: 'MM-DD-YYYY' + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('03-25-2025'); + + expect(field.value).toEqual(jasmine.any(Date)); + expect(field.value.getFullYear()).toBe(2025); + expect(field.value.getMonth()).toBe(2); + expect(field.value.getDate()).toBe(25); + expect(field.isValid).toBeTrue(); + }); + + it('should show validation error with i18n key when date is typed in wrong format', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE, + dateDisplayFormat: 'MM-DD-YYYY' + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('03/25/2025'); + + expect(widget.dateInputControl.invalid).toBeTrue(); + expect(field.isValid).toBeFalse(); + expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT'); + expect(field.validationSummary.attributes.get('format')).toBe('MM-DD-YYYY'); + }); + + it('should show validation error with format attribute using default format', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('invalid-text'); + + expect(widget.dateInputControl.invalid).toBeTrue(); + expect(field.isValid).toBeFalse(); + expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT'); + expect(field.validationSummary.attributes.get('format')).toBe('D-M-YYYY'); + }); + + it('should transition from invalid to valid when clearing input on non-required field', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('invalid-text'); + + expect(widget.dateInputControl.invalid).toBeTrue(); + expect(field.isValid).toBeFalse(); + + await testingUtils.fillMatInput(''); + + expect(widget.dateInputControl.valid).toBeTrue(); + expect(field.isValid).toBeTrue(); + expect(field.validationSummary.message).toBe(''); + }); + }); + + describe('addValidators idempotency', () => { + it('should not stack required validators on repeated updateReactiveFormControl calls', () => { + widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { + type: FormFieldTypes.DATE, + required: true + }); + widget.field.isVisible = true; + + fixture.detectChanges(); + + widget.updateReactiveFormControl(); + widget.updateReactiveFormControl(); + widget.updateReactiveFormControl(); + + widget.dateInputControl.setValue(new Date('2025-06-15')); + fixture.detectChanges(); + + expect(widget.field.isValid).toBeTrue(); + expect(widget.dateInputControl.valid).toBeTrue(); + }); + }); + + describe('async enrichment', () => { + it('should sync FormControl when field.value is set externally', () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE + }); + + widget.field = field; + fixture.detectChanges(); + + expect(widget.dateInputControl.value).toBeNull(); + + const enrichedDate = new Date('2025-06-15'); + field.value = enrichedDate; + widget.updateReactiveFormControl(); + + expect(widget.dateInputControl.value).toBe(enrichedDate); + }); + }); }); diff --git a/lib/core/src/lib/form/components/widgets/date/date.widget.ts b/lib/core/src/lib/form/components/widgets/date/date.widget.ts index a4a2b64eee..f42b0ae940 100644 --- a/lib/core/src/lib/form/components/widgets/date/date.widget.ts +++ b/lib/core/src/lib/form/components/widgets/date/date.widget.ts @@ -79,6 +79,7 @@ export class DateWidgetComponent extends WidgetComponent implements OnInit, Reac } updateReactiveFormControl(): void { + this.setFormControlValue(); this.updateFormControlState(); if (this.field?.form?.showAllValidationErrors) { this.dateInputControl.markAsTouched(); @@ -87,11 +88,17 @@ export class DateWidgetComponent extends WidgetComponent implements OnInit, Reac } private setFormControlValue(): void { - this.dateInputControl.setValue(this.field.value, { emitEvent: false }); + if (this.field.value !== this.dateInputControl.value) { + this.dateInputControl.setValue(this.field.value, { emitEvent: false }); + } } private updateFormControlState(): void { - this.dateInputControl.setValidators(this.isRequired() && this.field?.isVisible ? [Validators.required] : []); + if (this.isRequired() && this.field?.isVisible) { + this.dateInputControl.addValidators(Validators.required); + } else { + this.dateInputControl.removeValidators(Validators.required); + } this.field?.readOnly || this.readOnly ? this.dateInputControl.disable({ emitEvent: false }) : this.dateInputControl.enable({ emitEvent: false }); @@ -124,9 +131,12 @@ export class DateWidgetComponent extends WidgetComponent implements OnInit, Reac private handleErrors(errors: ValidationErrors): void { const errorAttributes = new Map(); switch (true) { - case !!errors.matDatepickerParse: - this.updateValidationSummary(this.field.dateDisplayFormat || this.field.defaultDateTimeFormat); + case !!errors.matDatepickerParse: { + const format = this.field.dateDisplayFormat || this.field.defaultDateTimeFormat; + errorAttributes.set('format', format); + this.updateValidationSummary('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT', errorAttributes); break; + } case !!errors.required: this.updateValidationSummary('FORM.FIELD.REQUIRED'); break; diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index b527af5b37..da1ddad4c2 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -62,6 +62,7 @@ "INVALID_DECIMAL_NUMBER": "Use a decimal number format", "INVALID_DECIMAL_PRECISION": "Incorrect decimal value, there should be a maximum of {{precision}} digits after the decimal point.", "INVALID_DATE": "Use a different date format", + "INVALID_DATE_FORMAT": "Invalid date format, use the format: {{ format }}", "INVALID_VALUE": "Enter a different value", "NOT_GREATER_THAN": "Can't be greater than {{ maxValue }}", "NOT_LESS_THAN": "Can't be less than {{ minValue }}", diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.spec.ts index 01e71f457d..c20f57aedb 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.spec.ts @@ -544,4 +544,135 @@ describe('DateCloudWidgetComponent', () => { expect(widget.dateInputControl.touched).toBe(false); }); }); + + describe('keyboard input', () => { + it('should process typed date in default format', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('25-03-2025'); + + expect(field.value).toEqual(jasmine.any(Date)); + expect(field.value.getFullYear()).toBe(2025); + expect(field.value.getMonth()).toBe(2); + expect(field.value.getDate()).toBe(25); + expect(field.isValid).toBeTrue(); + }); + + it('should process typed date in custom dateDisplayFormat', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE, + dateDisplayFormat: 'MM-DD-YYYY' + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('03-25-2025'); + + expect(field.value).toEqual(jasmine.any(Date)); + expect(field.value.getFullYear()).toBe(2025); + expect(field.value.getMonth()).toBe(2); + expect(field.value.getDate()).toBe(25); + expect(field.isValid).toBeTrue(); + }); + + it('should show validation error when date is typed in wrong format', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE, + dateDisplayFormat: 'MM-DD-YYYY' + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('03/25/2025'); + + expect(widget.dateInputControl.invalid).toBeTrue(); + expect(field.isValid).toBeFalse(); + expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT'); + expect(field.validationSummary.attributes.get('format')).toBe('MM-DD-YYYY'); + }); + }); + + describe('clearing invalid input', () => { + it('should transition from invalid to valid when clearing input on non-required field', async () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE + }); + + widget.field = field; + fixture.detectChanges(); + await fixture.whenStable(); + + await testingUtils.fillMatInput('invalid-text'); + + expect(widget.dateInputControl.invalid).toBeTrue(); + expect(field.isValid).toBeFalse(); + + await testingUtils.fillMatInput(''); + + expect(widget.dateInputControl.valid).toBeTrue(); + expect(field.isValid).toBeTrue(); + expect(field.validationSummary.message).toBe(''); + }); + }); + + describe('addValidators idempotency', () => { + it('should not stack required validators on repeated updateReactiveFormControl calls', () => { + widget.field = new FormFieldModel(new FormModel({ taskId: '' }), { + type: FormFieldTypes.DATE, + required: true + }); + widget.field.isVisible = true; + + fixture.detectChanges(); + + widget.updateReactiveFormControl(); + widget.updateReactiveFormControl(); + widget.updateReactiveFormControl(); + + widget.dateInputControl.setValue(new Date('2025-06-15')); + fixture.detectChanges(); + + expect(widget.field.isValid).toBeTrue(); + expect(widget.dateInputControl.valid).toBeTrue(); + }); + }); + + describe('async enrichment', () => { + it('should sync FormControl when field.value is set externally', () => { + const field = new FormFieldModel(form, { + id: 'date-field-id', + name: 'date-name', + type: FormFieldTypes.DATE + }); + + widget.field = field; + fixture.detectChanges(); + + expect(widget.dateInputControl.value).toBeNull(); + + const enrichedDate = new Date('2025-06-15'); + field.value = enrichedDate; + widget.updateReactiveFormControl(); + + expect(widget.dateInputControl.value).toBe(enrichedDate); + }); + }); }); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.ts index 4afc01f7ea..83b99fd3f8 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.ts @@ -95,11 +95,17 @@ export class DateCloudWidgetComponent extends WidgetComponent implements OnInit, } private setFormControlValue(): void { - this.dateInputControl.setValue(this.field.value, { emitEvent: false }); + if (this.field.value !== this.dateInputControl.value) { + this.dateInputControl.setValue(this.field.value, { emitEvent: false }); + } } private updateFormControlState(): void { - this.dateInputControl.setValidators(this.isRequired() && this.field?.isVisible ? [Validators.required] : []); + if (this.isRequired() && this.field?.isVisible) { + this.dateInputControl.addValidators(Validators.required); + } else { + this.dateInputControl.removeValidators(Validators.required); + } this.field?.readOnly || this.readOnly ? this.dateInputControl.disable({ emitEvent: false }) : this.dateInputControl.enable({ emitEvent: false }); @@ -132,9 +138,12 @@ export class DateCloudWidgetComponent extends WidgetComponent implements OnInit, private handleErrors(errors: ValidationErrors): void { const errorAttributes = new Map(); switch (true) { - case !!errors.matDatepickerParse: - this.updateValidationSummary(this.field.dateDisplayFormat || this.field.defaultDateTimeFormat); + case !!errors.matDatepickerParse: { + const format = this.field.dateDisplayFormat || this.field.defaultDateTimeFormat; + errorAttributes.set('format', format); + this.updateValidationSummary('FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT', errorAttributes); break; + } case !!errors.required: this.updateValidationSummary('FORM.FIELD.REQUIRED'); break;