From 0f5940a33cfd1d2e8815bc88fee610f44977f3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Ja=C5=9Bkowski?= <138671284+g-jaskowski@users.noreply.github.com> Date: Tue, 13 Jan 2026 09:43:51 +0100 Subject: [PATCH] [ACS-10514] fix leading and trailing operators (#4944) * [ACS-10514] add search input operators validator, search input control test coverage, search utils multiple whitespaces handling in split * [ACS-10514] review fixes * [ACS-10514] cr fixes --- projects/aca-content/assets/i18n/en.json | 3 +- .../search-input-control.component.spec.ts | 81 +++++++++++++++++-- .../search-input-control.component.ts | 6 +- .../src/lib/utils/aca-search-utils.spec.ts | 6 ++ .../src/lib/utils/aca-search-utils.ts | 2 +- ...ading-trailing-operators.validator.spec.ts | 70 ++++++++++++++++ ...no-leading-trailing-operators.validator.ts | 43 ++++++++++ projects/aca-shared/src/public-api.ts | 1 + 8 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 projects/aca-shared/src/lib/validators/no-leading-trailing-operators.validator.spec.ts create mode 100644 projects/aca-shared/src/lib/validators/no-leading-trailing-operators.validator.ts diff --git a/projects/aca-content/assets/i18n/en.json b/projects/aca-content/assets/i18n/en.json index f3da04a50..7ea1f0e17 100644 --- a/projects/aca-content/assets/i18n/en.json +++ b/projects/aca-content/assets/i18n/en.json @@ -595,7 +595,8 @@ "LIBRARIES": "Libraries", "MIN_LENGTH": "Search input must have at least 2 alphanumeric characters.", "REQUIRED": "Search input is required.", - "WHITESPACE": "Search input cannot be only whitespace." + "WHITESPACE": "Search input cannot be only whitespace.", + "OPERATORS": "Search input cannot begin with, end with or contain only operators." }, "SORT": { "SORTING_OPTION": "Sort by", diff --git a/projects/aca-content/src/lib/components/search/search-input-control/search-input-control.component.spec.ts b/projects/aca-content/src/lib/components/search/search-input-control/search-input-control.component.spec.ts index 1da62717e..76195eb67 100644 --- a/projects/aca-content/src/lib/components/search/search-input-control/search-input-control.component.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-input-control/search-input-control.component.spec.ts @@ -25,13 +25,18 @@ import { SearchInputControlComponent } from './search-input-control.component'; import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { AppTestingModule } from '../../../testing/app-testing.module'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core'; import { ActivatedRoute, NavigationStart, Router } from '@angular/router'; import { of } from 'rxjs'; +import { UnitTestingUtils } from '@alfresco/adf-core'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; describe('SearchInputControlComponent', () => { let fixture: ComponentFixture; let component: SearchInputControlComponent; + let unitTestingUtils: UnitTestingUtils; + let loader: HarnessLoader; beforeEach(() => { TestBed.configureTestingModule({ imports: [AppTestingModule, SearchInputControlComponent], @@ -39,9 +44,19 @@ describe('SearchInputControlComponent', () => { }); fixture = TestBed.createComponent(SearchInputControlComponent); component = fixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(fixture); + unitTestingUtils = new UnitTestingUtils(fixture.debugElement, loader); fixture.detectChanges(); }); + /** + * Sets the input value of the search input control component. + */ + function setInputValue(value: string) { + component.searchTerm = value; + fixture.detectChanges(); + } + it('should emit submit event if form is valid', () => { component.searchTerm = 'valid'; spyOn(component.submit, 'emit'); @@ -75,21 +90,29 @@ describe('SearchInputControlComponent', () => { }); it('should clear searchTerm', () => { - component.searchTerm = 'c'; - fixture.detectChanges(); + setInputValue('c'); component.clear(); expect(component.searchTerm).toBe(''); }); it('should check if searchTerm has a length less than 2', () => { - component.searchTerm = 'd'; - fixture.detectChanges(); + setInputValue('d'); expect(component.isTermTooShort()).toBe(true); - component.searchTerm = 'dd'; - fixture.detectChanges(); + setInputValue('dd'); expect(component.isTermTooShort()).toBe(false); }); + it('should mark searchFieldFormControl as untouched on blur', async () => { + spyOn(component.searchFieldFormControl, 'markAsUntouched').and.callThrough(); + const input = await unitTestingUtils.getMatInput(); + await input.setValue('test'); + expect(component.searchFieldFormControl.touched).toBeTrue(); + + await input.blur(); + expect(component.searchFieldFormControl.markAsUntouched).toHaveBeenCalled(); + expect(component.searchFieldFormControl.touched).toBeFalse(); + }); + describe('ngOnInit', () => { let route: ActivatedRoute; let router: Router; @@ -128,4 +151,48 @@ describe('SearchInputControlComponent', () => { expect(component.searchFieldFormControl.setValue).not.toHaveBeenCalled(); })); }); + + describe('validation error messages', () => { + beforeEach(() => { + spyOn(component.validationError, 'emit'); + }); + + it('should emit correct validation error message for whitespace validator', () => { + setInputValue(' '); + expect(component.validationError.emit).toHaveBeenCalledWith('SEARCH.INPUT.WHITESPACE'); + }); + + it('should emit correct validation error message for operators validator', () => { + setInputValue('AND word'); + expect(component.validationError.emit).toHaveBeenCalledWith('SEARCH.INPUT.OPERATORS'); + }); + + it('should emit correct validation error message for required validator', () => { + setInputValue(''); + expect(component.validationError.emit).toHaveBeenCalledWith('SEARCH.INPUT.REQUIRED'); + }); + + it('should update validation error when hasLibrariesConstraint changes from false to true with short search term', () => { + setInputValue('a'); + expect(component.validationError.emit).toHaveBeenCalledWith(''); + + component.hasLibrariesConstraint = true; + component.ngOnChanges({ + hasLibrariesConstraint: new SimpleChange(false, true, false) + }); + expect(component.validationError.emit).toHaveBeenCalledWith('SEARCH.INPUT.MIN_LENGTH'); + }); + + it('should clear validation error when hasLibrariesConstraint changes from true to false with short search term', () => { + component.hasLibrariesConstraint = true; + setInputValue('a'); + expect(component.validationError.emit).toHaveBeenCalledWith('SEARCH.INPUT.MIN_LENGTH'); + + component.hasLibrariesConstraint = false; + component.ngOnChanges({ + hasLibrariesConstraint: new SimpleChange(true, false, false) + }); + expect(component.validationError.emit).toHaveBeenCalledWith(''); + }); + }); }); diff --git a/projects/aca-content/src/lib/components/search/search-input-control/search-input-control.component.ts b/projects/aca-content/src/lib/components/search/search-input-control/search-input-control.component.ts index c085e3a0c..c09000623 100644 --- a/projects/aca-content/src/lib/components/search/search-input-control/search-input-control.component.ts +++ b/projects/aca-content/src/lib/components/search/search-input-control/search-input-control.component.ts @@ -44,7 +44,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { FormControl, FormsModule, ReactiveFormsModule, StatusChangeEvent, TouchedChangeEvent, Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { noWhitespaceValidator } from '@alfresco/aca-shared'; +import { noWhitespaceValidator, noLeadingTrailingOperatorsValidator } from '@alfresco/aca-shared'; import { combineLatest } from 'rxjs'; import { filter, startWith } from 'rxjs/operators'; import { ActivatedRoute, NavigationStart, Router } from '@angular/router'; @@ -95,7 +95,7 @@ export class SearchInputControlComponent implements OnInit, OnChanges { @ViewChild('searchInput', { static: true }) searchInput: ElementRef; - searchFieldFormControl = new FormControl('', [Validators.required, noWhitespaceValidator()]); + searchFieldFormControl = new FormControl('', [Validators.required, noWhitespaceValidator(), noLeadingTrailingOperatorsValidator()]); get searchTerm(): string { return this.searchFieldFormControl.value.replace('text:', 'TEXT:'); @@ -167,6 +167,8 @@ export class SearchInputControlComponent implements OnInit, OnChanges { const errors = this.searchFieldFormControl.errors; if (errors?.whitespace) { this.validationError.emit('SEARCH.INPUT.WHITESPACE'); + } else if (errors?.operators) { + this.validationError.emit('SEARCH.INPUT.OPERATORS'); } else if (errors?.required) { this.validationError.emit('SEARCH.INPUT.REQUIRED'); } else if (this.hasLibrariesConstraint && this.isTermTooShort()) { diff --git a/projects/aca-content/src/lib/utils/aca-search-utils.spec.ts b/projects/aca-content/src/lib/utils/aca-search-utils.spec.ts index b46cc8494..63a328cc1 100644 --- a/projects/aca-content/src/lib/utils/aca-search-utils.spec.ts +++ b/projects/aca-content/src/lib/utils/aca-search-utils.spec.ts @@ -118,6 +118,12 @@ describe('SearchUtils', () => { `(=cm:name:"test1.pdf" OR =cm:title:"test1.pdf") OR (=cm:name:"test2.pdf" OR =cm:title:"test2.pdf")` ); }); + + it('should split words correctly when multiple whitespaces are present', () => { + expect(formatSearchTerm(' big yellow ', ['cm:name', 'cm:title'])).toBe( + `(cm:name:"big*" OR cm:title:"big*") AND (cm:name:"yellow*" OR cm:title:"yellow*")` + ); + }); }); describe('extractUserQueryFromEncodedQuery', () => { diff --git a/projects/aca-content/src/lib/utils/aca-search-utils.ts b/projects/aca-content/src/lib/utils/aca-search-utils.ts index 30d6dfbca..236505279 100644 --- a/projects/aca-content/src/lib/utils/aca-search-utils.ts +++ b/projects/aca-content/src/lib/utils/aca-search-utils.ts @@ -78,7 +78,7 @@ export function formatSearchTerm(userInput: string, fields = ['cm:name']): strin return userInput; } - const words = userInput.split(' '); + const words = userInput.split(/\s+/); if (words.length > 1) { const separator = words.some(isOperator) ? ' ' : ' AND '; diff --git a/projects/aca-shared/src/lib/validators/no-leading-trailing-operators.validator.spec.ts b/projects/aca-shared/src/lib/validators/no-leading-trailing-operators.validator.spec.ts new file mode 100644 index 000000000..9e6a2d7f9 --- /dev/null +++ b/projects/aca-shared/src/lib/validators/no-leading-trailing-operators.validator.spec.ts @@ -0,0 +1,70 @@ +/*! + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Alfresco Example Content Application + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * from Hyland Software. If not, see . + */ + +import { FormControl } from '@angular/forms'; +import { noLeadingTrailingOperatorsValidator } from './no-leading-trailing-operators.validator'; + +describe('noLeadingTrailingOperatorsValidator', () => { + const validatorFn = noLeadingTrailingOperatorsValidator(); + + it('should return null for valid input without operators', () => { + const control = new FormControl('valid input'); + expect(validatorFn(control)).toBeNull(); + }); + + it('should return null for empty input', () => { + const control = new FormControl(''); + expect(validatorFn(control)).toBeNull(); + }); + + it('should return null for valid input with operators', () => { + const control = new FormControl('valid AND input OR phrase'); + expect(validatorFn(control)).toBeNull(); + }); + + it('should return error for input with leading AND operator', () => { + const control = new FormControl('AND input'); + expect(validatorFn(control)).toEqual({ operators: true }); + }); + + it('should return error for input with leading OR operator', () => { + const control = new FormControl('OR input'); + expect(validatorFn(control)).toEqual({ operators: true }); + }); + + it('should return error for input with trailing AND operator', () => { + const control = new FormControl('input AND'); + expect(validatorFn(control)).toEqual({ operators: true }); + }); + + it('should return error for input with trailing OR operator', () => { + const control = new FormControl('input OR'); + expect(validatorFn(control)).toEqual({ operators: true }); + }); + + it('should return error for input with only operator', () => { + const control = new FormControl('AND OR'); + expect(validatorFn(control)).toEqual({ operators: true }); + }); +}); diff --git a/projects/aca-shared/src/lib/validators/no-leading-trailing-operators.validator.ts b/projects/aca-shared/src/lib/validators/no-leading-trailing-operators.validator.ts new file mode 100644 index 000000000..255bdc0f2 --- /dev/null +++ b/projects/aca-shared/src/lib/validators/no-leading-trailing-operators.validator.ts @@ -0,0 +1,43 @@ +/*! + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Alfresco Example Content Application + * + * This file is part of the Alfresco Example Content Application. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * The Alfresco Example Content Application is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Alfresco Example Content Application is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * from Hyland Software. If not, see . + */ + +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +const isOperator = (word: string): boolean => { + const operators = ['AND', 'OR']; + return operators.includes(word.trim()); +}; + +export const noLeadingTrailingOperatorsValidator = (): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const rawValue = control.value; + if (!rawValue) { + return null; + } + + const words = rawValue.trim().split(/\s+/); + + return isOperator(words[0]) || isOperator(words[words.length - 1]) ? { operators: true } : null; + }; +}; diff --git a/projects/aca-shared/src/public-api.ts b/projects/aca-shared/src/public-api.ts index 791835901..9ee7a54eb 100644 --- a/projects/aca-shared/src/public-api.ts +++ b/projects/aca-shared/src/public-api.ts @@ -66,3 +66,4 @@ export * from './lib/testing/lib-testing-module'; export * from './lib/utils/node.utils'; export * from './lib/validators/no-whitespace.validator'; +export * from './lib/validators/no-leading-trailing-operators.validator';