From 0b35118dee2c1be9ad1c300a913c79bdcb72071e Mon Sep 17 00:00:00 2001 From: David Olson <157068235+DavidOlson-Hyland@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:11:28 -0500 Subject: [PATCH] AAE-47880 UI breaks when pasting very large text into text field (#12043) --- .../widgets/core/form-field-validator.spec.ts | 37 +++ .../widgets/core/form-field-validator.ts | 14 +- .../components/widgets/text/text.widget.html | 29 ++- .../widgets/text/text.widget.spec.ts | 233 ++++++++++++++++++ .../components/widgets/text/text.widget.ts | 85 +++++-- .../components/form-cloud.component.spec.ts | 5 +- 6 files changed, 378 insertions(+), 25 deletions(-) diff --git a/lib/core/src/lib/form/components/widgets/core/form-field-validator.spec.ts b/lib/core/src/lib/form/components/widgets/core/form-field-validator.spec.ts index 015b2854d0..62bc3f2cc3 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field-validator.spec.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field-validator.spec.ts @@ -19,6 +19,7 @@ import { ContainerModel } from './container.model'; import { ErrorMessageModel } from './error-message.model'; import { FormFieldTypes } from './form-field-types'; import { + DEFAULT_TEXT_MAX_LENGTH, FixedValueFieldValidator, MaxLengthFieldValidator, MaxValueFieldValidator, @@ -681,6 +682,42 @@ describe('FormFieldValidator', () => { expect(validator.isSupported(field)).toBe(true); }); + it('should support text field when fallback maxLength is defined', () => { + const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH); + const field = new FormFieldModel(new FormModel(), { + type: FormFieldTypes.TEXT + }); + + expect(fallbackValidator.isSupported(field)).toBe(true); + expect(fallbackValidator.getMaxLength(field)).toBe(DEFAULT_TEXT_MAX_LENGTH); + }); + + it('should validate text field against fallback maxLength when maxLength is not configured', () => { + const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH); + const field = new FormFieldModel(new FormModel(), { + type: FormFieldTypes.TEXT, + value: 'a'.repeat(DEFAULT_TEXT_MAX_LENGTH + 1) + }); + + field.validationSummary = new ErrorMessageModel(); + expect(fallbackValidator.validate(field)).toBe(false); + expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); + expect(field.validationSummary.attributes.get('maxLength')).toBe(DEFAULT_TEXT_MAX_LENGTH.toLocaleString()); + }); + + it('should use configured maxLength instead of fallback maxLength', () => { + const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH); + const field = new FormFieldModel(new FormModel(), { + type: FormFieldTypes.TEXT, + maxLength: 3, + value: '1234' + }); + + field.validationSummary = new ErrorMessageModel(); + expect(fallbackValidator.validate(field)).toBe(false); + expect(field.validationSummary.attributes.get('maxLength')).toBe('3'); + }); + it('should allow empty values', () => { const field = new FormFieldModel(new FormModel(), { type: FormFieldTypes.TEXT, diff --git a/lib/core/src/lib/form/components/widgets/core/form-field-validator.ts b/lib/core/src/lib/form/components/widgets/core/form-field-validator.ts index e6730630e3..d04a5220f4 100644 --- a/lib/core/src/lib/form/components/widgets/core/form-field-validator.ts +++ b/lib/core/src/lib/form/components/widgets/core/form-field-validator.ts @@ -21,6 +21,8 @@ import { FormFieldTypes } from './form-field-types'; import { isNumberValue } from './form-field-utils'; import { FormFieldModel } from './form-field.model'; +export const DEFAULT_TEXT_MAX_LENGTH = 1024; + export interface FormFieldValidator { isSupported(field: FormFieldModel): boolean; validate(field: FormFieldModel): boolean; @@ -135,7 +137,8 @@ export class MinLengthFieldValidator implements FormFieldValidator { export class MaxLengthFieldValidator implements FormFieldValidator { constructor( private readonly supportedTypes: FormFieldTypes[] = [FormFieldTypes.TEXT, FormFieldTypes.MULTILINE_TEXT], - private readonly maxLength?: number + private readonly maxLength?: number, + private readonly fallbackMaxLength?: number ) {} isSupported(field: FormFieldModel): boolean { @@ -158,7 +161,11 @@ export class MaxLengthFieldValidator implements FormFieldValidator { } getMaxLength(field: FormFieldModel): number | undefined { - return this.maxLength ?? field.maxLength; + if (this.maxLength !== undefined) { + return this.maxLength; + } + + return field.maxLength > 0 ? field.maxLength : this.fallbackMaxLength; } } @@ -317,7 +324,8 @@ export const FORM_FIELD_VALIDATORS = [ new RequiredFieldValidator(), new NumberFieldValidator(), new MinLengthFieldValidator(), - new MaxLengthFieldValidator(), + new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH), + new MaxLengthFieldValidator([FormFieldTypes.MULTILINE_TEXT]), new MaxLengthFieldValidator([FormFieldTypes.NUMBER], 10), new MinValueFieldValidator(), new MaxValueFieldValidator(), diff --git a/lib/core/src/lib/form/components/widgets/text/text.widget.html b/lib/core/src/lib/form/components/widgets/text/text.widget.html index ab5da84c10..e1fc6d47e0 100644 --- a/lib/core/src/lib/form/components/widgets/text/text.widget.html +++ b/lib/core/src/lib/form/components/widgets/text/text.widget.html @@ -1,5 +1,5 @@
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 0a99fe52f8..5272a89c43 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 @@ -142,6 +142,186 @@ describe('TextWidgetComponent', () => { expect(errors[0]).toContain('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); }); + it('should validate against default max length when maxLength is not configured', async () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: '', + type: FormFieldTypes.TEXT, + readOnly: false + }); + fixture.detectChanges(); + + await testingUtils.fillMatInput('a'.repeat(1025)); + + expect(widget.field.isValid).toBe(false); + expect(widget.field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); + expect(widget.field.validationSummary.attributes.get('maxLength')).toBe('1,024'); + }); + + it('should block paste when resulting value exceeds configured max length', () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: '1234', + type: FormFieldTypes.TEXT, + readOnly: false, + maxLength: 5 + }); + fixture.detectChanges(); + const inputElement = testingUtils.getByCSS('#text-id').nativeElement; + inputElement.value = '1234'; + inputElement.setSelectionRange(4, 4); + const pasteEvent = { + target: inputElement, + clipboardData: { getData: () => '56' }, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as ClipboardEvent; + + widget.onPaste(pasteEvent); + + expect(pasteEvent.preventDefault).toHaveBeenCalled(); + expect(widget.maxLengthPasteError.message).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); + expect(widget.maxLengthPasteError.attributes.get('maxLength')).toBe('5'); + + widget.field.validate(); + fixture.detectChanges(); + + const errorWidget = testingUtils.getByCSS('.adf-error-text').nativeElement; + expect(errorWidget.textContent.trim()).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); + expect(widget.maxLengthPasteError.isActive()).toBe(true); + }); + + it('should keep paste max length error when value change emits after blocked paste', () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: '1234', + type: FormFieldTypes.TEXT, + readOnly: false, + maxLength: 5 + }); + fixture.detectChanges(); + const inputElement = testingUtils.getByCSS('#text-id').nativeElement; + inputElement.value = '1234'; + inputElement.setSelectionRange(4, 4); + const pasteEvent = { + target: inputElement, + clipboardData: { getData: () => '56' }, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as ClipboardEvent; + + widget.onPaste(pasteEvent); + widget.onValueChange('123456'); + fixture.detectChanges(); + + expect(widget.maxLengthPasteError.isActive()).toBe(true); + const errorWidget = testingUtils.getByCSS('.adf-error-text').nativeElement; + expect(errorWidget.textContent.trim()).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); + }); + + it('should keep paste max length error when paste input event emits after blocked paste', () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: '1234', + type: FormFieldTypes.TEXT, + readOnly: false, + maxLength: 5 + }); + fixture.detectChanges(); + const inputElement = testingUtils.getByCSS('#text-id').nativeElement; + inputElement.value = '1234'; + inputElement.setSelectionRange(4, 4); + const pasteEvent = { + target: inputElement, + clipboardData: { getData: () => '56' }, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as ClipboardEvent; + + widget.onPaste(pasteEvent); + + widget.onInput({ inputType: 'insertFromPaste' } as unknown as Event); + + expect(widget.maxLengthPasteError.isActive()).toBe(true); + }); + + it('should clear paste max length error when non-paste input happens after blocked paste', () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: '1234', + type: FormFieldTypes.TEXT, + readOnly: false, + maxLength: 5 + }); + fixture.detectChanges(); + const inputElement = testingUtils.getByCSS('#text-id').nativeElement; + inputElement.value = '1234'; + inputElement.setSelectionRange(4, 4); + const pasteEvent = { + target: inputElement, + clipboardData: { getData: () => '56' }, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as ClipboardEvent; + + widget.onPaste(pasteEvent); + widget.onInput({ inputType: 'insertText' } as unknown as Event); + + expect(widget.maxLengthPasteError.isActive()).toBe(false); + }); + + it('should allow paste when selected text keeps resulting value within max length', () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: '12345', + type: FormFieldTypes.TEXT, + readOnly: false, + maxLength: 5 + }); + fixture.detectChanges(); + const inputElement = testingUtils.getByCSS('#text-id').nativeElement; + inputElement.value = '12345'; + inputElement.setSelectionRange(2, 5); + const pasteEvent = { + target: inputElement, + clipboardData: { getData: () => 'abc' }, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as ClipboardEvent; + + widget.onPaste(pasteEvent); + + expect(pasteEvent.preventDefault).not.toHaveBeenCalled(); + expect(widget.maxLengthPasteError.isActive()).toBe(false); + }); + + it('should block paste when resulting value exceeds default max length', () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: 'a'.repeat(1024), + type: FormFieldTypes.TEXT, + readOnly: false + }); + fixture.detectChanges(); + const inputElement = testingUtils.getByCSS('#text-id').nativeElement; + inputElement.value = 'a'.repeat(1024); + inputElement.setSelectionRange(1024, 1024); + const pasteEvent = { + target: inputElement, + clipboardData: { getData: () => 'b' }, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as ClipboardEvent; + + widget.onPaste(pasteEvent); + + expect(pasteEvent.preventDefault).toHaveBeenCalled(); + expect(widget.maxLengthPasteError.message).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); + expect(widget.maxLengthPasteError.attributes.get('maxLength')).toBe('1,024'); + expect(widget.maxLengthPasteError.isActive()).toBe(true); + }); + it('should be able to set regex pattern property for Text widget', async () => { widget.field = new FormFieldModel(form, { id: 'text-id', @@ -505,6 +685,59 @@ describe('TextWidgetComponent', () => { const customStatusMessage = testingUtils.getByCSS('.custom-status-message').nativeElement; expect(customStatusMessage?.textContent).toBe(`custom status message for ${widget.field.name}`); }); + + it('should display paste max length error with custom status template', () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: '1234', + type: FormFieldTypes.TEXT, + readOnly: false, + maxLength: 5 + }); + fixture.detectChanges(); + const inputElement = testingUtils.getByCSS('#text-id').nativeElement; + inputElement.value = '1234'; + inputElement.setSelectionRange(4, 4); + const pasteEvent = { + target: inputElement, + clipboardData: { getData: () => '56' }, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as ClipboardEvent; + + widget.onPaste(pasteEvent); + fixture.detectChanges(); + + const pasteErrorWidget = testingUtils.getByCSS('.adf-error-text').nativeElement; + expect(pasteErrorWidget.innerHTML).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); + }); + + it('should replace custom status template with paste max length error', () => { + widget.field = new FormFieldModel(form, { + id: 'text-id', + name: 'text-name', + value: '1234', + type: FormFieldTypes.TEXT, + readOnly: false, + maxLength: 5 + }); + fixture.detectChanges(); + const inputElement = testingUtils.getByCSS('#text-id').nativeElement; + inputElement.value = '1234'; + inputElement.setSelectionRange(4, 4); + const pasteEvent = { + target: inputElement, + clipboardData: { getData: () => '56' }, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as ClipboardEvent; + + widget.onPaste(pasteEvent); + fixture.detectChanges(); + + expect(testingUtils.getByCSS('.custom-status-message')).toBeNull(); + const pasteErrorWidget = testingUtils.getByCSS('.adf-error-text').nativeElement; + expect(pasteErrorWidget.innerHTML).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN'); + }); }); }); 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 c78c530c82..00261a4e26 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 @@ -26,7 +26,9 @@ 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 { FormattableTextWidgetComponent } from '../core/formattable-text.widget'; +import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator'; import { InputMaskDirective } from './text-mask.component'; import { IconModule } from '../../../../icon/icon.module'; @@ -67,12 +69,21 @@ export class FieldStatusTemplateDirective { encapsulation: ViewEncapsulation.None }) export class TextWidgetComponent extends FormattableTextWidgetComponent { - mask: string; - placeholder: string; - isMaskReversed: boolean; + mask = ''; + placeholder = ''; + isMaskReversed = false; fieldStatusTemplate = inject(FIELD_STATUS_TEMPLATE, { optional: true }); - errorStateMatcher: ErrorStateMatcher; + errorStateMatcher!: ErrorStateMatcher; translateParameters: Record