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-5600": "https://hyland.atlassian.net/browse/ACS-6928",
"XAT-17697": "https://hyland.atlassian.net/browse/ACS-7464", "XAT-17697": "https://hyland.atlassian.net/browse/ACS-7464",
"XAT-17121": "https://hyland.atlassian.net/browse/ACS-9795", "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 { AppConfigService, NotificationService } from '@alfresco/adf-core';
import { Component, DestroyRef, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { Component, DestroyRef, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; 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 { Store } from '@ngrx/store';
import { SearchInputControlComponent } from '../search-input-control/search-input-control.component'; import { SearchInputControlComponent } from '../search-input-control/search-input-control.component';
import { SearchNavigationService } from '../search-navigation.service'; import { SearchNavigationService } from '../search-navigation.service';
@@ -44,6 +44,8 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils'; import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { merge } from 'rxjs/internal/observable/merge';
import { filter, map, withLatestFrom } from 'rxjs';
@Component({ @Component({
imports: [ imports: [
@@ -119,13 +121,22 @@ export class SearchInputComponent implements OnInit, OnDestroy {
ngOnInit() { ngOnInit() {
this.showInputValue(); this.showInputValue();
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => { merge(
const encodedQuery = params['q']; this.route.queryParams,
if (encodedQuery && this.searchInputControl) { this.router.events.pipe(
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery); filter((e) => e instanceof NavigationSkipped),
this.searchInputControl.searchTerm = this.searchedWord; 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.appHookService.library400Error.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.has400LibraryError = true; this.has400LibraryError = true;

View File

@@ -29,7 +29,7 @@ import { Store } from '@ngrx/store';
import { NavigateToFolder } from '@alfresco/aca-shared/store'; import { NavigateToFolder } from '@alfresco/aca-shared/store';
import { Pagination, SearchRequest } from '@alfresco/js-api'; import { Pagination, SearchRequest } from '@alfresco/js-api';
import { SavedSearchesService, SearchQueryBuilderService } from '@alfresco/adf-content-services'; 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 { BehaviorSubject, of, Subject, throwError } from 'rxjs';
import { AppTestingModule } from '../../../testing/app-testing.module'; import { AppTestingModule } from '../../../testing/app-testing.module';
import { AppService } from '@alfresco/aca-shared'; import { AppService } from '@alfresco/aca-shared';
@@ -52,7 +52,9 @@ describe('SearchComponent', () => {
let router: Router; let router: Router;
let route: ActivatedRoute; let route: ActivatedRoute;
const searchRequest = {} as SearchRequest; 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 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 showInfoSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
let loader: HarnessLoader; let loader: HarnessLoader;
@@ -66,6 +68,14 @@ describe('SearchComponent', () => {
beforeEach(() => { beforeEach(() => {
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' }); 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({ TestBed.configureTestingModule({
imports: [AppTestingModule, SearchResultsComponent, MatSnackBarModule, MatMenuModule, NoopAnimationsModule], imports: [AppTestingModule, SearchResultsComponent, MatSnackBarModule, MatMenuModule, NoopAnimationsModule],
providers: [ providers: [
@@ -94,9 +104,11 @@ describe('SearchComponent', () => {
sortingPreferenceKey: '' sortingPreferenceKey: ''
} }
}, },
params: params.asObservable() params: params.asObservable(),
queryParams: queryParams.asObservable()
} }
} },
{ provide: Router, useValue: routerMock }
] ]
}); });
@@ -106,7 +118,6 @@ describe('SearchComponent', () => {
translate = TestBed.inject(TranslationService); translate = TestBed.inject(TranslationService);
router = TestBed.inject(Router); router = TestBed.inject(Router);
route = TestBed.inject(ActivatedRoute); route = TestBed.inject(ActivatedRoute);
route.queryParams = of({});
const notificationService = TestBed.inject(NotificationService); const notificationService = TestBed.inject(NotificationService);
showErrorSpy = spyOn(notificationService, 'showError'); showErrorSpy = spyOn(notificationService, 'showError');
@@ -305,5 +316,24 @@ describe('SearchComponent', () => {
expect(showErrorSpy).toHaveBeenCalledWith('APP.BROWSE.SEARCH.SAVE_SEARCH.EDIT_DIALOG.ERROR_MESSAGE'); 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); testHeader(SearchResultsComponent, false);
}); });

View File

@@ -24,7 +24,7 @@
import { ChangeDetectorRef, Component, inject, OnInit, ViewEncapsulation } from '@angular/core'; import { ChangeDetectorRef, Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { NodeEntry, Pagination, ResultSetPaging } from '@alfresco/js-api'; import { NodeEntry, Pagination, ResultSetPaging } from '@alfresco/js-api';
import { ActivatedRoute, Params } from '@angular/router'; import { ActivatedRoute, NavigationStart } from '@angular/router';
import { import {
AlfrescoViewerComponent, AlfrescoViewerComponent,
DocumentListComponent, DocumentListComponent,
@@ -64,7 +64,7 @@ import {
ToolbarComponent ToolbarComponent
} from '@alfresco/aca-shared'; } from '@alfresco/aca-shared';
import { SearchSortingDefinition } from '@alfresco/adf-content-services/lib/search/models/search-sorting-definition.interface'; 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 { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { SearchInputComponent } from '../search-input/search-input.component'; import { SearchInputComponent } from '../search-input/search-input.component';
@@ -85,7 +85,7 @@ import {
formatSearchTerm formatSearchTerm
} from '../../../utils/aca-search-utils'; } from '../../../utils/aca-search-utils';
import { SaveSearchDirective } from '../search-save/directive/save-search.directive'; 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@@ -145,7 +145,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
encodedQuery: string; encodedQuery: string;
searchConfig: SearchConfiguration; searchConfig: SearchConfiguration;
private readonly loadedFilters$ = new Subject<void>();
constructor( constructor(
tagsService: TagService, tagsService: TagService,
private readonly queryBuilder: SearchQueryBuilderService, private readonly queryBuilder: SearchQueryBuilderService,
@@ -163,13 +162,10 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
maxItems: 25 maxItems: 25
}; };
this.queryBuilder.configUpdated this.queryBuilder.configUpdated.pipe(takeUntilDestroyed()).subscribe((searchConfig) => {
.asObservable() this.searchConfig = searchConfig;
.pipe(takeUntilDestroyed()) this.updateUserQuery();
.subscribe((searchConfig) => { });
this.searchConfig = searchConfig;
this.updateUserQuery();
});
} }
ngOnInit() { ngOnInit() {
@@ -207,42 +203,55 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.columns = this.extensions.documentListPresets.searchResults || []; this.columns = this.extensions.documentListPresets.searchResults || [];
if (this.route) { if (this.route) {
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => { this.route.queryParams
this.savedSearchesService .pipe(
.getSavedSearches() takeUntilDestroyed(this.destroyRef),
.pipe(takeUntilDestroyed(this.destroyRef)) switchMap((params) =>
.subscribe((savedSearches) => { this.savedSearchesService.getSavedSearches().pipe(
const savedSearchFound = savedSearches.find((savedSearch) => savedSearch.encodedUrl === encodeURIComponent(params[this.queryParamName])); first(),
this.initialSavedSearch = savedSearchFound !== undefined ? savedSearchFound : this.initialSavedSearch; map((savedSearches) => savedSearches.find((savedSearch) => savedSearch.encodedUrl === encodeURIComponent(params[this.queryParamName])))
}); )
if (params[this.queryParamName]) { )
this.isLoading = true; )
} .subscribe((savedSearches) => {
this.loadedFilters$.next(); this.initialSavedSearch = savedSearches;
this.encodedQuery = params[this.queryParamName] || null; });
this.searchedWord = extractSearchedWordFromEncodedQuery(this.encodedQuery);
this.updateUserQuery(); combineLatest([
const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(this.encodedQuery); this.route.queryParams,
if (filtersFromEncodedQuery !== null) { this.router.events.pipe(
const filtersToLoad = this.queryBuilder.categories.length; filter((e): e is NavigationStart => e instanceof NavigationStart),
let loadedFilters = this.searchedWord === '' ? 0 : 1; startWith(null)
this.queryBuilder.filterLoaded )
.asObservable() ])
.pipe(takeUntilDestroyed(this.destroyRef), takeUntil(this.loadedFilters$)) .pipe(
.subscribe(() => { takeUntilDestroyed(this.destroyRef),
loadedFilters++; tap(([params]) => {
if (filtersToLoad === loadedFilters) { this.encodedQuery = params[this.queryParamName];
this.loadedFilters$.next(); this.isLoading = !!this.encodedQuery;
this.queryBuilder.execute(false);
} this.searchedWord = extractSearchedWordFromEncodedQuery(this.encodedQuery);
}); this.updateUserQuery();
this.queryBuilder.populateFilters.next(filtersFromEncodedQuery);
} else { const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(this.encodedQuery);
this.queryBuilder.populateFilters.next({}); this.queryBuilder.populateFilters.next(filtersFromEncodedQuery || {});
this.queryBuilder.execute(false); }),
} switchMap(([, navigationStartEvent]) => {
this.queryBuilder.userQuery = extractUserQueryFromEncodedQuery(this.encodedQuery); 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']); const updatedUserQuery = formatSearchTerm(this.searchedWord, this.searchConfig['app:fields']);
this.queryBuilder.userQuery = updatedUserQuery; 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"' }; const query = { userQuery: 'cm:name:"test"' };
expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('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', () => { describe('extractSearchedWordFromEncodedQuery', () => {
@@ -139,6 +144,11 @@ describe('SearchUtils', () => {
const query = { userQuery: 'cm:name:"test*"' }; const query = { userQuery: 'cm:name:"test*"' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('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', () => { describe('extractFiltersFromEncodedQuery', () => {

View File

@@ -99,7 +99,7 @@ export function formatSearchTerm(userInput: string, fields = ['cm:name']): strin
export function extractUserQueryFromEncodedQuery(encodedQuery: string): string { export function extractUserQueryFromEncodedQuery(encodedQuery: string): string {
if (encodedQuery) { if (encodedQuery) {
const decodedQuery: { [key: string]: any } = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(encodedQuery), (c) => c.charCodeAt(0)))); 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 ''; return '';
} }
@@ -118,7 +118,7 @@ export function extractSearchedWordFromEncodedQuery(encodedQuery: string): strin
.split('AND') .split('AND')
.map((searchCondition) => { .map((searchCondition) => {
const searchTerm = searchCondition.split('"')[1]; const searchTerm = searchCondition.split('"')[1];
return searchTerm === '*' ? searchTerm : searchTerm.slice(0, -1); return searchTerm?.endsWith('*') && searchTerm !== '*' ? searchTerm.slice(0, -1) : searchTerm;
}) })
.join(' ') .join(' ')
: ''; : '';
@@ -139,3 +139,14 @@ export function extractFiltersFromEncodedQuery(encodedQuery: string): any {
} }
return null; 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) { if (fileTypeInputText) {
await this.fileTypeInput?.fill(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(); await page.searchFilters.menuCardApply.click();