mirror of
https://github.com/Alfresco/alfresco-content-app.git
synced 2025-07-24 17:31:52 +00:00
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:
@@ -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"
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
});
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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', () => {
|
||||
|
@@ -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(/\)$/, '') ?? '';
|
||||
}
|
||||
|
@@ -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();
|
||||
|
Reference in New Issue
Block a user