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
This commit is contained in:
rmnvch
2025-07-16 12:23:08 +02:00
committed by GitHub
parent 18ed2e4583
commit 4b750f8b7d
7 changed files with 151 additions and 64 deletions

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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<any>;
let params: BehaviorSubject<Params>;
let queryParams: Subject<Params>;
let routerEvents: Subject<Event>;
let showErrorSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
let showInfoSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
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>('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);
});

View File

@@ -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<void>();
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;
}
}
}

View File

@@ -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', () => {

View File

@@ -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(/\)$/, '') ?? '';
}

View File

@@ -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();