AAE-47880 UI breaks when pasting very large text into text field (#12043)

This commit is contained in:
David Olson
2026-07-09 10:11:28 -05:00
committed by GitHub
parent fe2b3d85dd
commit 0b35118dee
6 changed files with 378 additions and 25 deletions
@@ -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,
@@ -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(),
@@ -1,5 +1,5 @@
<div class="adf-textfield adf-text-widget {{ field.className }}"
[class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-invalid]="(!field.isValid || maxLengthPasteError.isActive()) && isTouched()"
[class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels">
<div *ngIf="field.leftLabels">
@@ -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()))) {
<mat-error>
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<span class="adf-error-text">
@if (maxLengthPasteError.isActive()) {
{{ maxLengthPasteError.message | translate:maxLengthPasteErrorParameters }}
} @else if (field.validationSummary?.message) {
{{ field.validationSummary.message | translate:translateParameters }}
} @else {
{{ 'FORM.FIELD.REQUIRED' | translate }}
}
</span>
</mat-error>
}
</mat-form-field>
<ng-container *ngTemplateOutlet="fieldStatusTemplate ?? null; context: { $implicit: this }" />
<ng-container *ngTemplateOutlet="maxLengthPasteError.isActive() && fieldStatusTemplate ? maxLengthPasteErrorTemplate : (fieldStatusTemplate ?? null); context: { $implicit: this }" />
<ng-template #maxLengthPasteErrorTemplate>
<div class="adf-error-container adf-error-messages-container">
<div class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ maxLengthPasteError.message | translate:maxLengthPasteErrorParameters }}</div>
</div>
</div>
</ng-template>
</div>
</div>
@@ -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');
});
});
});
@@ -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<string, string> = {};
maxLengthPasteError = new ErrorMessageModel();
get resolvedMaxLength(): number {
return this.field?.maxLength > 0 ? this.field.maxLength : DEFAULT_TEXT_MAX_LENGTH;
}
get maxLengthPasteErrorParameters(): Record<string, string> {
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();
}
}
@@ -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', () => {