AAE-44002 Allow user input for date and datetime fields (#11791)

This commit is contained in:
Darren Thornton
2026-04-06 13:23:51 -05:00
committed by GitHub
parent 70a6f42649
commit 69a9dc1386
7 changed files with 424 additions and 21 deletions
@@ -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: '<id>' }), {
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: '<id>' }), {
@@ -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<string, string>();
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;
@@ -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<DateWidgetComponent>;
let adapter: DateAdapter<Date>;
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: '<id>' }), {
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);
});
});
});
@@ -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<string, string>();
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;
+1
View File
@@ -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 }}",
@@ -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: '<id>' }), {
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);
});
});
});
@@ -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<string, string>();
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;