[ACS-8751] Adapt search results to handle query encoding and state propagation

This commit is contained in:
MichalKinas
2024-10-03 20:57:46 +02:00
parent de8f48cff0
commit 02d829fc5d
13 changed files with 463 additions and 259 deletions

36
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@ngrx/store": "^15.2.0",
"@ngrx/store-devtools": "^15.2.0",
"@ngx-translate/core": "^14.0.0",
"buffer": "^6.0.3",
"date-fns": "^2.30.0",
"material-icons": "^1.13.12",
"minimatch-browser": "^1.0.0",
@@ -12362,7 +12363,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -12471,6 +12471,30 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/blocking-proxy": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/blocking-proxy/-/blocking-proxy-1.0.1.tgz",
@@ -12652,10 +12676,9 @@
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
@@ -12672,7 +12695,7 @@
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
@@ -18408,7 +18431,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",

View File

@@ -53,6 +53,7 @@
"@ngrx/store": "^15.2.0",
"@ngrx/store-devtools": "^15.2.0",
"@ngx-translate/core": "^14.0.0",
"buffer": "^6.0.3",
"date-fns": "^2.30.0",
"material-icons": "^1.13.12",
"minimatch-browser": "^1.0.0",

View File

@@ -32,15 +32,18 @@ import { AppHookService, AppService } from '@alfresco/aca-shared';
import { map } from 'rxjs/operators';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchNavigationService } from '../search-navigation.service';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { NotificationService } from '@alfresco/adf-core';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Buffer } from 'buffer';
import { ActivatedRoute } from '@angular/router';
describe('SearchInputComponent', () => {
let fixture: ComponentFixture<SearchInputComponent>;
let component: SearchInputComponent;
let actions$: Actions;
let appHookService: AppHookService;
let route: ActivatedRoute;
let searchInputService: SearchNavigationService;
let showErrorSpy: jasmine.Spy;
@@ -50,6 +53,10 @@ describe('SearchInputComponent', () => {
toggleAppNavBar$: new Subject()
};
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
};
beforeEach(() => {
appServiceMock.setAppNavbarMode.calls.reset();
TestBed.configureTestingModule({
@@ -68,6 +75,7 @@ describe('SearchInputComponent', () => {
fixture = TestBed.createComponent(SearchInputComponent);
appHookService = TestBed.inject(AppHookService);
searchInputService = TestBed.inject(SearchNavigationService);
route = TestBed.inject(ActivatedRoute);
component = fixture.componentInstance;
const notificationService = TestBed.inject(NotificationService);
@@ -147,35 +155,10 @@ describe('SearchInputComponent', () => {
});
describe('onSearchChange()', () => {
it('should call search action with correct search options', (done) => {
it('should call search action with correct searched term', () => {
const searchedTerm = 's';
const currentSearchOptions = [{ key: 'SEARCH.INPUT.FILES' }];
actions$
.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
expect(action.searchOptions[0].key).toBe(currentSearchOptions[0].key);
})
)
.subscribe(() => {
done();
});
component.onSearchChange(searchedTerm);
});
it('should call search action with correct searched term', (done) => {
const searchedTerm = 's';
actions$
.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
expect(action.payload).toBe(searchedTerm);
})
)
.subscribe(() => {
done();
});
component.onSearchChange(searchedTerm);
expect(component.searchedWord).toBe(searchedTerm);
});
it('should show snack for empty search', () => {
@@ -246,4 +229,14 @@ describe('SearchInputComponent', () => {
expect(appServiceMock.setAppNavbarMode).toHaveBeenCalledWith('expanded');
});
it('should extract searched word from query params', (done) => {
route.queryParams = of({ q: encodeQuery({ userQuery: 'cm:name:"test*"' }) });
route.queryParams.subscribe(() => {
fixture.detectChanges();
expect(component.searchedWord).toBe('test');
done();
});
fixture.detectChanges();
});
});

View File

@@ -28,10 +28,10 @@ import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { AppConfigService, NotificationService } from '@alfresco/adf-core';
import { Component, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { NavigationEnd, PRIMARY_OUTLET, Router, RouterEvent, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { ActivatedRoute, Params, PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { takeUntil } from 'rxjs/operators';
import { SearchInputControlComponent } from '../search-input-control/search-input-control.component';
import { SearchNavigationService } from '../search-navigation.service';
import { SearchLibrariesQueryBuilderService } from '../search-libraries-results/search-libraries-query-builder.service';
@@ -44,6 +44,7 @@ import { MatInputModule } from '@angular/material/input';
import { A11yModule } from '@angular/cdk/a11y';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { FormsModule } from '@angular/forms';
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
@Component({
standalone: true,
@@ -70,9 +71,6 @@ export class SearchInputComponent implements OnInit, OnDestroy {
private notificationService = inject(NotificationService);
onDestroy$: Subject<boolean> = new Subject<boolean>();
hasOneChange = false;
hasNewChange = false;
navigationTimer: any;
has400LibraryError = false;
hasLibrariesConstraint = false;
searchOnChange: boolean;
@@ -110,6 +108,7 @@ export class SearchInputComponent implements OnInit, OnDestroy {
private queryLibrariesBuilder: SearchLibrariesQueryBuilderService,
private config: AppConfigService,
private router: Router,
private route: ActivatedRoute,
private store: Store<AppStore>,
private appHookService: AppHookService,
private appService: AppService,
@@ -121,14 +120,14 @@ export class SearchInputComponent implements OnInit, OnDestroy {
ngOnInit() {
this.showInputValue();
this.router.events
.pipe(takeUntil(this.onDestroy$))
.pipe(filter((e) => e instanceof RouterEvent))
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.showInputValue();
}
});
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
const encodedQuery = params['q'] ? params['q'] : null;
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
if (this.searchInputControl) {
this.searchInputControl.searchTerm = this.searchedWord;
}
});
this.appHookService.library400Error.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
this.has400LibraryError = true;
@@ -192,24 +191,6 @@ export class SearchInputComponent implements OnInit, OnDestroy {
this.has400LibraryError = false;
this.hasLibrariesConstraint = this.evaluateLibrariesConstraint();
this.searchedWord = searchTerm;
if (this.hasOneChange) {
this.hasNewChange = true;
} else {
this.hasOneChange = true;
}
if (this.hasNewChange) {
clearTimeout(this.navigationTimer);
this.hasNewChange = false;
}
this.navigationTimer = setTimeout(() => {
if (searchTerm) {
this.store.dispatch(new SearchByTermAction(searchTerm, this.searchOptions));
}
this.hasOneChange = false;
}, 1000);
}
searchByOption() {
@@ -305,7 +286,7 @@ export class SearchInputComponent implements OnInit, OnDestroy {
if (urlSegmentGroup) {
const urlSegments: UrlSegment[] = urlSegmentGroup.segments;
searchTerm = urlSegments[0].parameters['q'] ? decodeURIComponent(urlSegments[0].parameters['q']) : '';
searchTerm = extractSearchedWordFromEncodedQuery(urlSegments[0].parameters['q']);
}
}

View File

@@ -27,13 +27,16 @@ import { AppTestingModule } from '../../../testing/app-testing.module';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { SearchLibrariesResultsComponent } from './search-libraries-results.component';
import { SearchLibrariesQueryBuilderService } from './search-libraries-query-builder.service';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { AppService } from '@alfresco/aca-shared';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { Buffer } from 'buffer';
describe('SearchLibrariesResultsComponent', () => {
let component: SearchLibrariesResultsComponent;
let fixture: ComponentFixture<SearchLibrariesResultsComponent>;
let route: ActivatedRoute;
const emptyPage = { list: { pagination: { totalItems: 0 }, entries: [] } };
const appServiceMock = {
@@ -42,6 +45,10 @@ describe('SearchLibrariesResultsComponent', () => {
setAppNavbarMode: jasmine.createSpy('setAppNavbarMode')
};
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
};
beforeEach(() => {
appServiceMock.setAppNavbarMode.calls.reset();
TestBed.configureTestingModule({
@@ -56,6 +63,7 @@ describe('SearchLibrariesResultsComponent', () => {
]
});
route = TestBed.inject(ActivatedRoute);
fixture = TestBed.createComponent(SearchLibrariesResultsComponent);
component = fixture.componentInstance;
});
@@ -72,4 +80,14 @@ describe('SearchLibrariesResultsComponent', () => {
expect(appServiceMock.setAppNavbarMode).toHaveBeenCalledWith('collapsed');
});
it('should extract searched word from query params', (done) => {
route.queryParams = of({ q: encodeQuery({ userQuery: 'cm:name:"test*"' }) });
route.queryParams.subscribe(() => {
fixture.detectChanges();
expect(component.searchedWord).toBe('test');
done();
});
fixture.detectChanges();
});
});

View File

@@ -45,6 +45,8 @@ import { CustomEmptyContentTemplateDirective, DataColumnComponent, DataColumnLis
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { DocumentListDirective } from '../../../directives/document-list.directive';
import { DocumentListComponent } from '@alfresco/adf-content-services';
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
import { takeUntil } from 'rxjs/operators';
@Component({
standalone: true,
@@ -128,14 +130,12 @@ export class SearchLibrariesResultsComponent extends PageComponent implements On
);
if (this.route) {
this.route.params.forEach((params: Params) => {
// eslint-disable-next-line no-prototype-builtins
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
const query = this.formatSearchQuery(this.searchedWord);
if (query && query.length > 1) {
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
const encodedQuery = params[this.queryParamName] ? params[this.queryParamName] : null;
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
if (this.searchedWord?.length > 1) {
this.librariesQueryBuilder.paging.skipCount = 0;
this.librariesQueryBuilder.userQuery = query;
this.librariesQueryBuilder.userQuery = this.searchedWord;
this.librariesQueryBuilder.update();
} else {
this.librariesQueryBuilder.userQuery = null;
@@ -147,13 +147,6 @@ export class SearchLibrariesResultsComponent extends PageComponent implements On
}
}
private formatSearchQuery(userInput: string) {
if (!userInput) {
return null;
}
return userInput.trim();
}
onSearchResultLoaded(nodePaging: NodePaging) {
this.data = nodePaging;
this.totalResults = this.getNumberOfResults();

View File

@@ -30,10 +30,11 @@ import { NavigateToFolder } from '@alfresco/aca-shared/store';
import { Pagination, SearchRequest } from '@alfresco/js-api';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { AppService } from '@alfresco/aca-shared';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Buffer } from 'buffer';
describe('SearchComponent', () => {
let component: SearchResultsComponent;
@@ -43,10 +44,15 @@ describe('SearchComponent', () => {
let queryBuilder: SearchQueryBuilderService;
let translate: TranslationService;
let router: Router;
let route: ActivatedRoute;
const searchRequest = {} as SearchRequest;
let params: BehaviorSubject<any>;
let showErrorSpy: jasmine.Spy;
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
};
beforeEach(() => {
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' });
TestBed.configureTestingModule({
@@ -79,6 +85,8 @@ describe('SearchComponent', () => {
queryBuilder = TestBed.inject(SearchQueryBuilderService);
translate = TestBed.inject(TranslationService);
router = TestBed.inject(Router);
route = TestBed.inject(ActivatedRoute);
route.queryParams = of({});
const notificationService = TestBed.inject(NotificationService);
showErrorSpy = spyOn(notificationService, 'showError');
@@ -149,79 +157,6 @@ describe('SearchComponent', () => {
expect(showErrorSpy).toHaveBeenCalledWith('Generic Error');
}));
it('should decode encoded URI', () => {
expect(queryBuilder.userQuery).toEqual('(TYPE: "cm:folder" AND (=cm: name: email OR cm: name: budget))');
});
it('should return null if formatting invalid query', () => {
expect(component.formatSearchQuery(null)).toBeNull();
expect(component.formatSearchQuery('')).toBeNull();
});
it('should use original user input if text contains colons', () => {
const query = 'TEXT:test OR TYPE:folder';
expect(component.formatSearchQuery(query)).toBe(query);
});
it('should be able to search if search input contains https url', () => {
const query = component.formatSearchQuery('https://alfresco.com');
expect(query).toBe(`(cm:name:"https://alfresco.com*")`);
});
it('should be able to search if search input contains http url', () => {
const query = component.formatSearchQuery('http://alfresco.com');
expect(query).toBe(`(cm:name:"http://alfresco.com*")`);
});
it('should use original user input if text contains quotes', () => {
const query = `"Hello World"`;
expect(component.formatSearchQuery(query)).toBe(query);
});
it('should format user input according to the configuration fields', () => {
const query = component.formatSearchQuery('hello', ['cm:name', 'cm:title']);
expect(query).toBe(`(cm:name:"hello*" OR cm:title:"hello*")`);
});
it('should format user input as cm:name if configuration not provided', () => {
const query = component.formatSearchQuery('hello');
expect(query).toBe(`(cm:name:"hello*")`);
});
it('should use AND operator when conjunction has no operators', () => {
const query = component.formatSearchQuery('big yellow banana', ['cm:name']);
expect(query).toBe(`(cm:name:"big*") AND (cm:name:"yellow*") AND (cm:name:"banana*")`);
});
it('should support conjunctions with AND operator', () => {
const query = component.formatSearchQuery('big AND yellow AND banana', ['cm:name', 'cm:title']);
expect(query).toBe(
`(cm:name:"big*" OR cm:title:"big*") AND (cm:name:"yellow*" OR cm:title:"yellow*") AND (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support conjunctions with OR operator', () => {
const query = component.formatSearchQuery('big OR yellow OR banana', ['cm:name', 'cm:title']);
expect(query).toBe(
`(cm:name:"big*" OR cm:title:"big*") OR (cm:name:"yellow*" OR cm:title:"yellow*") OR (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support exact term matching with default fields', () => {
const query = component.formatSearchQuery('=orange', ['cm:name', 'cm:title']);
expect(query).toBe(`(=cm:name:"orange" OR =cm:title:"orange")`);
});
it('should support exact term matching with operators', () => {
const query = component.formatSearchQuery('=test1.pdf or =test2.pdf', ['cm:name', 'cm:title']);
expect(query).toBe(`(=cm:name:"test1.pdf" OR =cm:title:"test1.pdf") or (=cm:name:"test2.pdf" OR =cm:title:"test2.pdf")`);
});
it('should navigate to folder on double click', () => {
const node: any = {
entry: {
@@ -267,16 +202,26 @@ describe('SearchComponent', () => {
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should update the user query whenever param changed', () => {
params.next({ q: '=orange' });
expect(queryBuilder.userQuery).toBe(`((=cm:name:"orange"))`);
expect(queryBuilder.update).toHaveBeenCalled();
it('should update the user query, populate filters state and execute query whenever param changed', (done) => {
spyOn(queryBuilder.populateFilters, 'next');
spyOn(queryBuilder, 'execute');
const query = { userQuery: 'cm:tag:"orange*"', filterProp: { prop: 'test' } };
route.queryParams = of({ q: encodeQuery(query) });
component.ngOnInit();
route.queryParams.subscribe(() => {
expect(component.searchedWord).toBe(`orange`);
expect(queryBuilder.userQuery).toBe(`(cm:tag:"orange*")`);
expect(queryBuilder.populateFilters.next).toHaveBeenCalledWith({ userQuery: 'cm:tag:"orange*"', filterProp: { prop: 'test' } });
queryBuilder.filterLoaded.next();
fixture.detectChanges();
expect(queryBuilder.execute).toHaveBeenCalledWith(false);
done();
});
});
it('should update the user query whenever configuration changed', () => {
params.next({ q: '=orange' });
component.searchedWord = 'orange';
queryBuilder.configUpdated.next({ 'app:fields': ['cm:tag'] } as any);
expect(queryBuilder.userQuery).toBe(`((=cm:tag:"orange"))`);
expect(queryBuilder.update).toHaveBeenCalled();
expect(queryBuilder.userQuery).toBe(`((cm:tag:"orange*"))`);
});
});

View File

@@ -52,7 +52,6 @@ import {
TranslationService,
ViewerToolbarComponent
} from '@alfresco/adf-core';
import { combineLatest } from 'rxjs';
import {
ContextActionsDirective,
InfoDrawerComponent,
@@ -78,6 +77,12 @@ import { SearchResultsRowComponent } from '../search-results-row/search-results-
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-actions-dropdown.component';
import { SearchAiInputContainerComponent } from '../../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
import {
extractFiltersFromEncodedQuery,
extractSearchedWordFromEncodedQuery,
extractUserQueryFromEncodedQuery,
formatSearchTerm
} from '../../../utils/aca-search-utils';
@Component({
standalone: true,
@@ -148,14 +153,13 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
maxItems: 25
};
combineLatest([this.route.params, this.queryBuilder.configUpdated])
this.queryBuilder.configUpdated
.asObservable()
.pipe(takeUntil(this.onDestroy$))
.subscribe(([params, searchConfig]) => {
// eslint-disable-next-line no-prototype-builtins
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
const query = this.formatSearchQuery(this.searchedWord, searchConfig['app:fields']);
if (query) {
this.queryBuilder.userQuery = decodeURIComponent(query);
.subscribe((searchConfig) => {
const updatedUserQuery = formatSearchTerm(this.searchedWord, searchConfig['app:fields']);
if (updatedUserQuery) {
this.queryBuilder.userQuery = updatedUserQuery;
}
});
}
@@ -189,17 +193,26 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.columns = this.extensions.documentListPresets.searchResults || [];
if (this.route) {
this.route.params.forEach((params: Params) => {
// eslint-disable-next-line no-prototype-builtins
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
if (this.searchedWord) {
this.queryBuilder.update();
} else {
this.queryBuilder.userQuery = null;
this.queryBuilder.executed.next({
list: { pagination: { totalItems: 0 }, entries: [] }
});
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
const encodedQuery = params[this.queryParamName] ? params[this.queryParamName] : null;
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(encodedQuery);
if (filtersFromEncodedQuery !== null) {
const filtersToLoad = Object.keys(filtersFromEncodedQuery).length;
let loadedFilters = this.searchedWord === '' ? 0 : 1;
this.queryBuilder.filterLoaded
.asObservable()
.pipe(takeUntil(this.onDestroy$))
.subscribe(() => {
loadedFilters++;
if (this.data === undefined && filtersToLoad === loadedFilters) {
this.queryBuilder.execute(false);
}
});
this.queryBuilder.populateFilters.next(filtersFromEncodedQuery);
}
this.queryBuilder.userQuery = extractUserQueryFromEncodedQuery(encodedQuery);
});
}
}
@@ -217,59 +230,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.notificationService.showError(message);
}
private isOperator(input: string): boolean {
if (input) {
input = input.trim().toUpperCase();
const operators = ['AND', 'OR'];
return operators.includes(input);
}
return false;
}
private formatFields(fields: string[], term: string): string {
let prefix = '';
let suffix = '*';
if (term.startsWith('=')) {
prefix = '=';
suffix = '';
term = term.substring(1);
}
if (term === '*') {
prefix = '';
suffix = '';
}
return '(' + fields.map((field) => `${prefix}${field}:"${term}${suffix}"`).join(' OR ') + ')';
}
formatSearchQuery(userInput: string, fields = ['cm:name']) {
if (!userInput) {
return null;
}
if (/^http[s]?:\/\//.test(userInput)) {
return this.formatFields(fields, userInput);
}
userInput = userInput.trim();
if (userInput.includes(':') || userInput.includes('"')) {
return userInput;
}
const words = userInput.split(' ');
if (words.length > 1) {
const separator = words.some(this.isOperator) ? ' ' : ' AND ';
return words.map((term) => (this.isOperator(term) ? term : this.formatFields(fields, term))).join(separator);
}
return this.formatFields(fields, userInput);
}
onSearchResultLoaded(nodePaging: ResultSetPaging) {
this.data = nodePaging;
this.totalResults = this.getNumberOfResults();

View File

@@ -29,10 +29,12 @@ import { EffectsModule } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { SearchOptionIds, SearchByTermAction, SearchAction } from '@alfresco/aca-shared/store';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
describe('SearchEffects', () => {
let store: Store<any>;
let router: Router;
let queryBuilder: SearchQueryBuilderService;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -41,18 +43,21 @@ describe('SearchEffects', () => {
store = TestBed.inject(Store);
router = TestBed.inject(Router);
queryBuilder = TestBed.inject(SearchQueryBuilderService);
spyOn(router, 'navigateByUrl').and.stub();
});
describe('searchByTerm$', () => {
it('should navigate to `search` when search options has library false', fakeAsync(() => {
spyOn(queryBuilder, 'navigateToSearch');
store.dispatch(new SearchByTermAction('test', []));
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/search;q=test');
expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('(cm:name:"test*")', '/search');
}));
it('should navigate to `search-libraries` when search options has library true', fakeAsync(() => {
spyOn(queryBuilder, 'navigateToSearch');
store.dispatch(
new SearchByTermAction('test', [
{
@@ -66,23 +71,7 @@ describe('SearchEffects', () => {
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/search-libraries;q=test');
}));
it('should encode search string for parentheses', fakeAsync(() => {
store.dispatch(new SearchByTermAction('(test)', []));
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/search;q=%2528test%2529');
}));
it('should encode %', fakeAsync(() => {
store.dispatch(new SearchByTermAction('%test%', []));
tick();
expect(router.navigateByUrl).toHaveBeenCalledWith('/search;q=%2525test%2525');
expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('(cm:name:"test*")', '/search-libraries');
}));
});

View File

@@ -23,15 +23,18 @@
*/
import { Actions, ofType, createEffect } from '@ngrx/effects';
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { SearchAction, SearchActionTypes, SearchByTermAction, SearchOptionIds } from '@alfresco/aca-shared/store';
import { Router } from '@angular/router';
import { SearchNavigationService } from '../../components/search/search-navigation.service';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { formatSearchTerm } from '../../utils/aca-search-utils';
@Injectable()
export class SearchEffects {
constructor(private actions$: Actions, private router: Router, private searchNavigationService: SearchNavigationService) {}
private actions$ = inject(Actions);
private queryBuilder = inject(SearchQueryBuilderService);
private searchNavigationService = inject(SearchNavigationService);
search$ = createEffect(
() =>
@@ -49,14 +52,13 @@ export class SearchEffects {
this.actions$.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
const query = action.payload.replace(/%/g, '%25').replace(/[(]/g, '%28').replace(/[)]/g, '%29');
const query = formatSearchTerm(action.payload, this.queryBuilder.config['app:fields']);
const libItem = action.searchOptions.find((item) => item.id === SearchOptionIds.Libraries);
const librarySelected = !!libItem && libItem.value;
if (librarySelected) {
this.router.navigateByUrl('/search-libraries;q=' + encodeURIComponent(query));
this.queryBuilder.navigateToSearch(query, '/search-libraries');
} else {
this.router.navigateByUrl('/search;q=' + encodeURIComponent(query));
this.queryBuilder.navigateToSearch(query, '/search');
}
})
),

View File

@@ -0,0 +1,155 @@
/*!
* Copyright © 2005-2024 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 <http://www.gnu.org/licenses/>.
*/
import {
extractFiltersFromEncodedQuery,
extractSearchedWordFromEncodedQuery,
extractUserQueryFromEncodedQuery,
formatSearchTerm,
formatSearchTermByFields,
isOperator
} from './aca-search-utils';
import { Buffer } from 'buffer';
describe('SearchUtils', () => {
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
};
describe('isOperator', () => {
it('should detect AND operator', () => {
expect(isOperator('AND')).toBeTrue();
});
it('should detect OR operator', () => {
expect(isOperator('OR')).toBeTrue();
});
it('should return false when operator is not present', () => {
expect(isOperator('WITH')).toBeFalse();
});
it('should return false when input is not valid', () => {
expect(isOperator(null)).toBeFalse();
expect(isOperator(undefined)).toBeFalse();
});
});
describe('formatSearchTermByFields', () => {
it('should append "*" to search term', () => {
expect(formatSearchTermByFields('test', ['name'])).toBe('(name:"test*")');
});
it('should not prefix when search term equals "*"', () => {
expect(formatSearchTermByFields('*', ['name'])).toBe('(name:"*")');
});
it('should properly handle search terms starting with "="', () => {
expect(formatSearchTermByFields('=test', ['name'])).toBe('(=name:"test")');
});
it('should format search term with set of fields and join with OR', () => {
expect(formatSearchTermByFields('test', ['name', 'size'])).toBe('(name:"test*" OR size:"test*")');
});
});
describe('formatSearchTerm', () => {
it('should return null when input is invalid', () => {
expect(formatSearchTerm(null)).toBeNull();
expect(formatSearchTerm(undefined)).toBeNull();
});
it('should not transfer custom queries', () => {
expect(formatSearchTerm('test:"term"')).toBe('test:"term"');
expect(formatSearchTerm('"test"')).toBe('"test"');
});
it('should properly join multiple word search term', () => {
expect(formatSearchTerm('test word term')).toBe('(cm:name:"test*") AND (cm:name:"word*") AND (cm:name:"term*")');
expect(formatSearchTerm('test word term', ['name', 'size'])).toBe(
'(name:"test*" OR size:"test*") AND (name:"word*" OR size:"word*") AND (name:"term*" OR size:"term*")'
);
});
it('should format user input as cm:name if configuration not provided', () => {
expect(formatSearchTerm('hello')).toBe(`(cm:name:"hello*")`);
});
it('should support conjunctions with AND operator', () => {
expect(formatSearchTerm('big AND yellow AND banana', ['cm:name', 'cm:title'])).toBe(
`(cm:name:"big*" OR cm:title:"big*") AND (cm:name:"yellow*" OR cm:title:"yellow*") AND (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support conjunctions with OR operator', () => {
expect(formatSearchTerm('big OR yellow OR banana', ['cm:name', 'cm:title'])).toBe(
`(cm:name:"big*" OR cm:title:"big*") OR (cm:name:"yellow*" OR cm:title:"yellow*") OR (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support exact term matching with operators', () => {
expect(formatSearchTerm('=test1.pdf or =test2.pdf', ['cm:name', 'cm:title'])).toBe(
`(=cm:name:"test1.pdf" OR =cm:title:"test1.pdf") or (=cm:name:"test2.pdf" OR =cm:title:"test2.pdf")`
);
});
});
describe('extractUserQueryFromEncodedQuery', () => {
it('should return empty string when encoded query is invalid', () => {
expect(extractUserQueryFromEncodedQuery(null)).toBe('');
expect(extractUserQueryFromEncodedQuery(undefined)).toBe('');
});
it('should properly extract user query', () => {
const query = { userQuery: 'cm:name:"test"' };
expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('cm:name:"test"');
});
});
describe('extractSearchedWordFromEncodedQuery', () => {
it('should return empty string when encoded query is invalid', () => {
const query = { otherProp: 'test' };
expect(extractSearchedWordFromEncodedQuery(null)).toBe('');
expect(extractSearchedWordFromEncodedQuery(undefined)).toBe('');
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('');
});
it('should properly extract search term', () => {
const query = { userQuery: 'cm:name:"test*"' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('test');
});
});
describe('extractFiltersFromEncodedQuery', () => {
it('should return null when encoded query is invalid', () => {
expect(extractFiltersFromEncodedQuery(null)).toBeNull();
expect(extractFiltersFromEncodedQuery(undefined)).toBeNull();
});
it('should properly parse encoded object', () => {
const query = { userQuery: 'cm:name:"test*"', filterProp: 'test' };
expect(extractFiltersFromEncodedQuery(encodeQuery(query))).toEqual(query);
});
});
});

View File

@@ -0,0 +1,144 @@
/*!
* Copyright © 2005-2024 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 <http://www.gnu.org/licenses/>.
*/
import { Buffer } from 'buffer';
/**
* Checks if string is an AND or OR operator
*
* @param input string to check if it is an operator
* @returns boolean
*/
export function isOperator(input: string): boolean {
if (input) {
input = input.trim().toUpperCase();
const operators = ['AND', 'OR'];
return operators.includes(input);
}
return false;
}
/**
* Formats a search term by provided fields
*
* @param term search term
* @param fields array of fields
* @returns string
*/
export function formatSearchTermByFields(term: string, fields: string[]): string {
let prefix = '';
let suffix = '*';
if (term.startsWith('=')) {
prefix = '=';
suffix = '';
term = term.substring(1);
}
if (term === '*') {
prefix = '';
suffix = '';
}
return '(' + fields.map((field) => `${prefix}${field}:"${term}${suffix}"`).join(' OR ') + ')';
}
/**
* Formats a search term, splits by words, skips custom queries containing ':' or '"'
*
* @param userInput search term
* @param fields array of fields
* @returns string
*/
export function formatSearchTerm(userInput: string, fields = ['cm:name']): string {
if (!userInput) {
return null;
}
userInput = userInput.trim();
if (userInput.includes(':') || userInput.includes('"')) {
return userInput;
}
const words = userInput.split(' ');
if (words.length > 1) {
const separator = words.some(isOperator) ? ' ' : ' AND ';
return words.map((term) => (isOperator(term) ? term : formatSearchTermByFields(term, fields))).join(separator);
}
return formatSearchTermByFields(userInput, fields);
}
/**
* Decodes a query and extracts the user query
*
* @param encodedQuery encoded query
* @returns string
*/
export function extractUserQueryFromEncodedQuery(encodedQuery: string): string {
if (encodedQuery) {
const decodedQuery: { [key: string]: any } = JSON.parse(Buffer.from(encodedQuery, 'base64').toString('ascii'));
return decodedQuery.userQuery;
}
return '';
}
/**
* Extracts user query from encoded query and splits it to get a search term
*
* @param encodedQuery encoded query
* @returns string
*/
export function extractSearchedWordFromEncodedQuery(encodedQuery: string): string {
if (encodedQuery) {
const userQuery = extractUserQueryFromEncodedQuery(encodedQuery);
return userQuery !== '' && userQuery !== undefined
? userQuery
.split('AND')
.map((searchCondition) => {
const searchTerm = searchCondition.split('"')[1];
return searchTerm === '*' ? searchTerm : searchTerm.slice(0, -1);
})
.join(' ')
: '';
}
return '';
}
/**
* Extracts filters configuration from encoded query
*
* @param encodedQuery encoded query
* @returns object containing filters configuration
*/
export function extractFiltersFromEncodedQuery(encodedQuery: string): any {
if (encodedQuery) {
const decodedQuery = Buffer.from(encodedQuery, 'base64').toString('ascii');
return JSON.parse(decodedQuery);
}
return null;
}

View File

@@ -31,3 +31,4 @@ export * from './lib/aca-content.routes';
export * from './lib/extensions/core.extensions.module';
export * from './lib/store/initial-state';
export * from './lib/services/content-url.service';
export * from './lib/utils/aca-search-utils';