From 4b750f8b7d70dea91a257582f0c9c2298e95ce43 Mon Sep 17 00:00:00 2001 From: rmnvch <90370279+rmnvch@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:23:08 +0200 Subject: [PATCH] MNT-25070: ADW - Search request is altered when using quote marks (#4666) * [MNT-25070] adjust helper functions to handle special characters in queries * [MNT-25070] fixes doubled requests on imperative navigation * [MNT-25070] introduces unit tests for added search utility features * [MNT-25070] clean-up * [MNT-25070] adjusts [XAT-5579] e2e * [MNT-25070] exclude failed e2e * [MNT-25070] readebility improvements; memory leaks handling * [MNT-25070] migrate from nested subscription to switchMap * [MNT-25070] adds unit tests for changes * [MNT-25070] removes any type from test setup * [MNT-25070] fixes same custom query reapply parsing issue --- e2e/playwright/search/exclude.tests.json | 6 +- .../search-input/search-input.component.ts | 27 +++-- .../search-results.component.spec.ts | 40 ++++++- .../search-results.component.ts | 113 ++++++++++-------- .../src/lib/utils/aca-search-utils.spec.ts | 10 ++ .../src/lib/utils/aca-search-utils.ts | 15 ++- .../search-filters-properties.component.ts | 4 +- 7 files changed, 151 insertions(+), 64 deletions(-) diff --git a/e2e/playwright/search/exclude.tests.json b/e2e/playwright/search/exclude.tests.json index 95fd43c75..05d2b54a1 100644 --- a/e2e/playwright/search/exclude.tests.json +++ b/e2e/playwright/search/exclude.tests.json @@ -3,5 +3,9 @@ "XAT-5600": "https://hyland.atlassian.net/browse/ACS-6928", "XAT-17697": "https://hyland.atlassian.net/browse/ACS-7464", "XAT-17121": "https://hyland.atlassian.net/browse/ACS-9795", - "XAT-17702": "https://hyland.atlassian.net/browse/ACS-9795" + "XAT-17702": "https://hyland.atlassian.net/browse/ACS-9795", + "XAT-17701": "https://hyland.atlassian.net/browse/ACS-9860", + "XAT-17700": "https://hyland.atlassian.net/browse/ACS-9860", + "XAT-5581": "https://hyland.atlassian.net/browse/ACS-9860", + "XAT-5589": "https://hyland.atlassian.net/browse/ACS-9860" } diff --git a/projects/aca-content/src/lib/components/search/search-input/search-input.component.ts b/projects/aca-content/src/lib/components/search/search-input/search-input.component.ts index a1cd802b8..204998302 100644 --- a/projects/aca-content/src/lib/components/search/search-input/search-input.component.ts +++ b/projects/aca-content/src/lib/components/search/search-input/search-input.component.ts @@ -28,7 +28,7 @@ import { SearchQueryBuilderService } from '@alfresco/adf-content-services'; import { AppConfigService, NotificationService } from '@alfresco/adf-core'; import { Component, DestroyRef, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; -import { ActivatedRoute, Params, PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router'; +import { ActivatedRoute, NavigationSkipped, Params, PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router'; import { Store } from '@ngrx/store'; import { SearchInputControlComponent } from '../search-input-control/search-input-control.component'; import { SearchNavigationService } from '../search-navigation.service'; @@ -44,6 +44,8 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; import { FormsModule } from '@angular/forms'; import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { merge } from 'rxjs/internal/observable/merge'; +import { filter, map, withLatestFrom } from 'rxjs'; @Component({ imports: [ @@ -119,13 +121,22 @@ export class SearchInputComponent implements OnInit, OnDestroy { ngOnInit() { this.showInputValue(); - this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => { - const encodedQuery = params['q']; - if (encodedQuery && this.searchInputControl) { - this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery); - this.searchInputControl.searchTerm = this.searchedWord; - } - }); + merge( + this.route.queryParams, + this.router.events.pipe( + filter((e) => e instanceof NavigationSkipped), + withLatestFrom(this.route.queryParams), + map(([, params]) => params) + ) + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((params: Params) => { + const encodedQuery = params['q']; + if (encodedQuery && this.searchInputControl) { + this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery); + this.searchInputControl.searchTerm = this.searchedWord; + } + }); this.appHookService.library400Error.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { this.has400LibraryError = true; diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.spec.ts b/projects/aca-content/src/lib/components/search/search-results/search-results.component.spec.ts index bbc76a384..7cbc06767 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.spec.ts @@ -29,7 +29,7 @@ import { Store } from '@ngrx/store'; import { NavigateToFolder } from '@alfresco/aca-shared/store'; import { Pagination, SearchRequest } from '@alfresco/js-api'; import { SavedSearchesService, SearchQueryBuilderService } from '@alfresco/adf-content-services'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Event, NavigationStart, Params, Router } from '@angular/router'; import { BehaviorSubject, of, Subject, throwError } from 'rxjs'; import { AppTestingModule } from '../../../testing/app-testing.module'; import { AppService } from '@alfresco/aca-shared'; @@ -52,7 +52,9 @@ describe('SearchComponent', () => { let router: Router; let route: ActivatedRoute; const searchRequest = {} as SearchRequest; - let params: BehaviorSubject; + let params: BehaviorSubject; + let queryParams: Subject; + let routerEvents: Subject; let showErrorSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef>; let showInfoSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef>; let loader: HarnessLoader; @@ -66,6 +68,14 @@ describe('SearchComponent', () => { beforeEach(() => { params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' }); + queryParams = new Subject(); + routerEvents = new Subject(); + + const routerMock = jasmine.createSpyObj('Router', ['navigate'], { + url: '/mock-search-url', + events: routerEvents + }); + TestBed.configureTestingModule({ imports: [AppTestingModule, SearchResultsComponent, MatSnackBarModule, MatMenuModule, NoopAnimationsModule], providers: [ @@ -94,9 +104,11 @@ describe('SearchComponent', () => { sortingPreferenceKey: '' } }, - params: params.asObservable() + params: params.asObservable(), + queryParams: queryParams.asObservable() } - } + }, + { provide: Router, useValue: routerMock } ] }); @@ -106,7 +118,6 @@ describe('SearchComponent', () => { translate = TestBed.inject(TranslationService); router = TestBed.inject(Router); route = TestBed.inject(ActivatedRoute); - route.queryParams = of({}); const notificationService = TestBed.inject(NotificationService); showErrorSpy = spyOn(notificationService, 'showError'); @@ -305,5 +316,24 @@ describe('SearchComponent', () => { expect(showErrorSpy).toHaveBeenCalledWith('APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.ERROR_MESSAGE'); })); + it('should call execute once on page reload', fakeAsync(() => { + spyOn(queryBuilder, 'execute'); + queryParams.next({ q: encodeQuery({ userQuery: 'cm:name:"test*"' }) }); + + tick(); + + expect(queryBuilder.execute).toHaveBeenCalledTimes(1); + })); + + it('should NOT call execute on navigation to search page', fakeAsync(() => { + spyOn(queryBuilder, 'execute'); + routerEvents.next(new NavigationStart(1, '/mock-search-url', 'imperative')); + queryParams.next({ q: encodeQuery({ userQuery: 'cm:name:"test*"' }) }); + + tick(); + + expect(queryBuilder.execute).not.toHaveBeenCalled(); + })); + testHeader(SearchResultsComponent, false); }); diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts b/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts index bdc1d0db7..a1ed1ec2e 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts @@ -24,7 +24,7 @@ import { ChangeDetectorRef, Component, inject, OnInit, ViewEncapsulation } from '@angular/core'; import { NodeEntry, Pagination, ResultSetPaging } from '@alfresco/js-api'; -import { ActivatedRoute, Params } from '@angular/router'; +import { ActivatedRoute, NavigationStart } from '@angular/router'; import { AlfrescoViewerComponent, DocumentListComponent, @@ -64,7 +64,7 @@ import { ToolbarComponent } from '@alfresco/aca-shared'; import { SearchSortingDefinition } from '@alfresco/adf-content-services/lib/search/models/search-sorting-definition.interface'; -import { take, takeUntil } from 'rxjs/operators'; +import { filter, first, map, startWith, switchMap, take, tap, toArray } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; import { SearchInputComponent } from '../search-input/search-input.component'; @@ -85,7 +85,7 @@ import { formatSearchTerm } from '../../../utils/aca-search-utils'; import { SaveSearchDirective } from '../search-save/directive/save-search.directive'; -import { Subject } from 'rxjs'; +import { combineLatest, of } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatMenuModule } from '@angular/material/menu'; @@ -145,7 +145,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit { encodedQuery: string; searchConfig: SearchConfiguration; - private readonly loadedFilters$ = new Subject(); constructor( tagsService: TagService, private readonly queryBuilder: SearchQueryBuilderService, @@ -163,13 +162,10 @@ export class SearchResultsComponent extends PageComponent implements OnInit { maxItems: 25 }; - this.queryBuilder.configUpdated - .asObservable() - .pipe(takeUntilDestroyed()) - .subscribe((searchConfig) => { - this.searchConfig = searchConfig; - this.updateUserQuery(); - }); + this.queryBuilder.configUpdated.pipe(takeUntilDestroyed()).subscribe((searchConfig) => { + this.searchConfig = searchConfig; + this.updateUserQuery(); + }); } ngOnInit() { @@ -207,42 +203,55 @@ export class SearchResultsComponent extends PageComponent implements OnInit { this.columns = this.extensions.documentListPresets.searchResults || []; if (this.route) { - this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => { - this.savedSearchesService - .getSavedSearches() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((savedSearches) => { - const savedSearchFound = savedSearches.find((savedSearch) => savedSearch.encodedUrl === encodeURIComponent(params[this.queryParamName])); - this.initialSavedSearch = savedSearchFound !== undefined ? savedSearchFound : this.initialSavedSearch; - }); - if (params[this.queryParamName]) { - this.isLoading = true; - } - this.loadedFilters$.next(); - this.encodedQuery = params[this.queryParamName] || null; - this.searchedWord = extractSearchedWordFromEncodedQuery(this.encodedQuery); - this.updateUserQuery(); - const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(this.encodedQuery); - if (filtersFromEncodedQuery !== null) { - const filtersToLoad = this.queryBuilder.categories.length; - let loadedFilters = this.searchedWord === '' ? 0 : 1; - this.queryBuilder.filterLoaded - .asObservable() - .pipe(takeUntilDestroyed(this.destroyRef), takeUntil(this.loadedFilters$)) - .subscribe(() => { - loadedFilters++; - if (filtersToLoad === loadedFilters) { - this.loadedFilters$.next(); - this.queryBuilder.execute(false); - } - }); - this.queryBuilder.populateFilters.next(filtersFromEncodedQuery); - } else { - this.queryBuilder.populateFilters.next({}); - this.queryBuilder.execute(false); - } - this.queryBuilder.userQuery = extractUserQueryFromEncodedQuery(this.encodedQuery); - }); + this.route.queryParams + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((params) => + this.savedSearchesService.getSavedSearches().pipe( + first(), + map((savedSearches) => savedSearches.find((savedSearch) => savedSearch.encodedUrl === encodeURIComponent(params[this.queryParamName]))) + ) + ) + ) + .subscribe((savedSearches) => { + this.initialSavedSearch = savedSearches; + }); + + combineLatest([ + this.route.queryParams, + this.router.events.pipe( + filter((e): e is NavigationStart => e instanceof NavigationStart), + startWith(null) + ) + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + tap(([params]) => { + this.encodedQuery = params[this.queryParamName]; + this.isLoading = !!this.encodedQuery; + + this.searchedWord = extractSearchedWordFromEncodedQuery(this.encodedQuery); + this.updateUserQuery(); + + const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(this.encodedQuery); + this.queryBuilder.populateFilters.next(filtersFromEncodedQuery || {}); + }), + switchMap(([, navigationStartEvent]) => { + const filtersToLoad = this.queryBuilder.categories.length; + + const filtersAreLoaded = filtersToLoad ? this.queryBuilder.filterLoaded.pipe(take(filtersToLoad), toArray()) : of(null); + + return filtersAreLoaded.pipe(map(() => navigationStartEvent)); + }) + ) + .subscribe((navigationStartEvent) => { + const shouldExecuteQuery = this.shouldExecuteQuery(navigationStartEvent, this.encodedQuery); + this.queryBuilder.userQuery = extractUserQueryFromEncodedQuery(this.encodedQuery); + + if (shouldExecuteQuery) { + this.queryBuilder.execute(false); + } + }); } } @@ -338,4 +347,14 @@ export class SearchResultsComponent extends PageComponent implements OnInit { const updatedUserQuery = formatSearchTerm(this.searchedWord, this.searchConfig['app:fields']); this.queryBuilder.userQuery = updatedUserQuery; } + + private shouldExecuteQuery(navigationStartEvent: NavigationStart | null, query: string | undefined): boolean { + if (!navigationStartEvent || navigationStartEvent.navigationTrigger === 'popstate' || navigationStartEvent.navigationTrigger === 'hashchange') { + return true; + } else if (navigationStartEvent.navigationTrigger === 'imperative') { + return false; + } else { + return !!query; + } + } } 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 45dccc02b..f1c38f6ee 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 @@ -125,6 +125,11 @@ describe('SearchUtils', () => { const query = { userQuery: 'cm:name:"test"' }; expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('cm:name:"test"'); }); + + it('should properly trim set of parentheses from extracted user query', () => { + const query = { userQuery: '(cm:name:"test")' }; + expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('cm:name:"test"'); + }); }); describe('extractSearchedWordFromEncodedQuery', () => { @@ -139,6 +144,11 @@ describe('SearchUtils', () => { const query = { userQuery: 'cm:name:"test*"' }; expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('test'); }); + + it('should properly extract search term for custom search', () => { + const query = { userQuery: '"test"' }; + expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('test'); + }); }); describe('extractFiltersFromEncodedQuery', () => { 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 4941afb9f..bad9ad4f8 100644 --- a/projects/aca-content/src/lib/utils/aca-search-utils.ts +++ b/projects/aca-content/src/lib/utils/aca-search-utils.ts @@ -99,7 +99,7 @@ export function formatSearchTerm(userInput: string, fields = ['cm:name']): strin export function extractUserQueryFromEncodedQuery(encodedQuery: string): string { if (encodedQuery) { const decodedQuery: { [key: string]: any } = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(encodedQuery), (c) => c.charCodeAt(0)))); - return decodedQuery.userQuery; + return trimUserQuery(decodedQuery.userQuery); } return ''; } @@ -118,7 +118,7 @@ export function extractSearchedWordFromEncodedQuery(encodedQuery: string): strin .split('AND') .map((searchCondition) => { const searchTerm = searchCondition.split('"')[1]; - return searchTerm === '*' ? searchTerm : searchTerm.slice(0, -1); + return searchTerm?.endsWith('*') && searchTerm !== '*' ? searchTerm.slice(0, -1) : searchTerm; }) .join(' ') : ''; @@ -139,3 +139,14 @@ export function extractFiltersFromEncodedQuery(encodedQuery: string): any { } return null; } + +/** + * Trims one set of parentheses from parsed user query. + * + * @param userQuery user query parsed from encoded query + * @returns string + */ +function trimUserQuery(userQuery: string): string { + const trimmedQuery = userQuery?.replace(/^\(/, ''); + return trimmedQuery?.replace(/\)$/, '') ?? ''; +} diff --git a/projects/aca-playwright-shared/src/page-objects/components/search/search-filters/search-filters-properties.component.ts b/projects/aca-playwright-shared/src/page-objects/components/search/search-filters/search-filters-properties.component.ts index eae326a36..1bbe09a50 100644 --- a/projects/aca-playwright-shared/src/page-objects/components/search/search-filters/search-filters-properties.component.ts +++ b/projects/aca-playwright-shared/src/page-objects/components/search/search-filters/search-filters-properties.component.ts @@ -90,7 +90,9 @@ export class SearchFiltersProperties extends BaseComponent { if (fileTypeInputText) { await this.fileTypeInput?.fill(fileTypeInputText); - await this.dropdownOptions.first().click(); + const targetDropdownOption = this.page.locator(`mat-option`, { hasText: fileTypeInputText }); + + await targetDropdownOption.click(); } await page.searchFilters.menuCardApply.click();