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 @@
@@ -26,15 +26,32 @@ [placeholder]="placeholder" [title]="field.tooltip" [errorStateMatcher]="errorStateMatcher" + (input)="onInput($event)" + (paste)="onPaste($event)" (blur)="onBlur()"> - @if (!fieldStatusTemplate && (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched()))) { + @if (!fieldStatusTemplate && (maxLengthPasteError.isActive() || field.validationSummary?.message || (isInvalidFieldRequired() && isTouched()))) { - error_outline - @if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}} + + + @if (maxLengthPasteError.isActive()) { + {{ maxLengthPasteError.message | translate:maxLengthPasteErrorParameters }} + } @else if (field.validationSummary?.message) { + {{ field.validationSummary.message | translate:translateParameters }} + } @else { + {{ 'FORM.FIELD.REQUIRED' | translate }} + } + } - + + +
+
+ +
{{ maxLengthPasteError.message | translate:maxLengthPasteErrorParameters }}
+
+
+
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 = {}; + maxLengthPasteError = new ErrorMessageModel(); + + get resolvedMaxLength(): number { + return this.field?.maxLength > 0 ? this.field.maxLength : DEFAULT_TEXT_MAX_LENGTH; + } + + get maxLengthPasteErrorParameters(): Record { + return this.maxLengthPasteError.getAttributesAsJsonObj(); + } override ngOnInit() { super.ngOnInit(); @@ -88,19 +99,28 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent { this.initErrorStateMatcher(); } - private initErrorStateMatcher(): void { - this.errorStateMatcher = { - isErrorState: (_control: UntypedFormControl | null, _form: FormGroupDirective | NgForm | null): boolean => - !this.fieldStatusTemplate && (!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched())) - }; + onPaste(event: ClipboardEvent): void { + const input = event.target instanceof HTMLInputElement ? event.target : null; + const pastedValue = event.clipboardData?.getData('text') ?? ''; + + if (!input || this.getLengthAfterPaste(input, pastedValue) <= this.resolvedMaxLength) { + this.clearMaxLengthPasteError(); + return; + } + + event.preventDefault(); + this.markAsTouched(); + this.setMaxLengthPasteError(); } - private updateTranslateParameters(): void { - if (this.field.validationSummary?.isActive()) { - this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj(); - } else { - this.translateParameters = {}; + onInput(event: Event): void { + const inputType = 'inputType' in event ? event.inputType : undefined; + + if (inputType === 'insertFromPaste') { + return; } + + this.clearMaxLengthPasteError(); } onBlur(): void { @@ -112,4 +132,41 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent { this.onFieldChanged(this.field); this.updateTranslateParameters(); } + + private initErrorStateMatcher(): void { + this.errorStateMatcher = { + isErrorState: (_control: UntypedFormControl | null, _form: FormGroupDirective | NgForm | null): boolean => + !this.fieldStatusTemplate && + (this.maxLengthPasteError.isActive() || + !!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 = {}; + } + } + + private getLengthAfterPaste(input: HTMLInputElement, pastedValue: string): number { + const value = input.value ?? ''; + const selectionStart = input.selectionStart ?? value.length; + const selectionEnd = input.selectionEnd ?? selectionStart; + + return value.length - Math.max(selectionEnd - selectionStart, 0) + pastedValue.length; + } + + private setMaxLengthPasteError(): void { + this.maxLengthPasteError = new ErrorMessageModel({ + message: 'FORM.FIELD.VALIDATOR.NO_LONGER_THAN', + attributes: new Map([['maxLength', this.resolvedMaxLength.toLocaleString()]]) + }); + } + + private clearMaxLengthPasteError(): void { + this.maxLengthPasteError = new ErrorMessageModel(); + } } diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts index 2ea4a8c50d..be804c6bc3 100644 --- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts @@ -34,7 +34,8 @@ import { AuthModule, FormFieldEvent, NoopTranslateModule, - NoopAuthModule + NoopAuthModule, + FORM_FIELD_VALIDATORS } from '@alfresco/adf-core'; import { Node } from '@alfresco/js-api'; import { ESCAPE } from '@angular/cdk/keycodes'; @@ -1394,7 +1395,7 @@ describe('FormCloudComponent', () => { formComponent.formCloudRepresentationJSON = new FormCloudRepresentation(JSON.parse(JSON.stringify(cloudFormMock))); const form = formComponent.parseForm(formComponent.formCloudRepresentationJSON); expect(formComponent.fieldValidators.length).toBe(1); - expect(form.fieldValidators.length).toBe(11); + expect(form.fieldValidators.length).toBe(FORM_FIELD_VALIDATORS.length + formComponent.fieldValidators.length); }); describe('form validations', () => {