diff --git a/package-lock.json b/package-lock.json index d2333217bb..dc55e0d97c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -12356,7 +12357,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", @@ -12465,6 +12465,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", @@ -12646,10 +12670,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", @@ -12666,7 +12689,7 @@ ], "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-from": { @@ -18402,7 +18425,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", diff --git a/package.json b/package.json index 388908a228..7061d24e1f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/projects/aca-content/src/lib/components/search/search-input/search-input.component.spec.ts b/projects/aca-content/src/lib/components/search/search-input/search-input.component.spec.ts index 74b37cd238..dc5582e36f 100644 --- a/projects/aca-content/src/lib/components/search/search-input/search-input.component.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-input/search-input.component.spec.ts @@ -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; 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) => { - const searchedTerm = 's'; - const currentSearchOptions = [{ key: 'SEARCH.INPUT.FILES' }]; - actions$ - .pipe( - ofType(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) => { + it('should call search action with correct searched term', () => { const searchedTerm = 's'; - actions$ - .pipe( - ofType(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(); + }); }); 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 48ac7da335..5230169b4f 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,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 = new Subject(); - 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, 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']); } } diff --git a/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.spec.ts b/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.spec.ts index 0716806ff7..1c9fd14ba1 100644 --- a/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.spec.ts @@ -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; + 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(); + }); }); diff --git a/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.ts b/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.ts index f38b4aa282..533e66c903 100644 --- a/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.ts +++ b/projects/aca-content/src/lib/components/search/search-libraries-results/search-libraries-results.component.ts @@ -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(); 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 841025d0b3..2f639c2cec 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 @@ -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'; import { testHeader } from '../../../testing/document-base-page-utils'; describe('SearchComponent', () => { @@ -44,10 +45,15 @@ describe('SearchComponent', () => { let queryBuilder: SearchQueryBuilderService; let translate: TranslationService; let router: Router; + let route: ActivatedRoute; const searchRequest = {} as SearchRequest; let params: BehaviorSubject; 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({ @@ -80,6 +86,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'); @@ -150,79 +158,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: { @@ -268,17 +203,27 @@ 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*"))`); }); 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 0598ca69a8..8b88948674 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 @@ -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(); diff --git a/projects/aca-content/src/lib/store/effects/search.effects.spec.ts b/projects/aca-content/src/lib/store/effects/search.effects.spec.ts index 7bc7d0f314..8f6b64dd9d 100644 --- a/projects/aca-content/src/lib/store/effects/search.effects.spec.ts +++ b/projects/aca-content/src/lib/store/effects/search.effects.spec.ts @@ -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; 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'); })); }); diff --git a/projects/aca-content/src/lib/store/effects/search.effects.ts b/projects/aca-content/src/lib/store/effects/search.effects.ts index 5dd35d4220..752c3fa88a 100644 --- a/projects/aca-content/src/lib/store/effects/search.effects.ts +++ b/projects/aca-content/src/lib/store/effects/search.effects.ts @@ -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(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'); } }) ), 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 new file mode 100644 index 0000000000..20a49f813a --- /dev/null +++ b/projects/aca-content/src/lib/utils/aca-search-utils.spec.ts @@ -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 . + */ + +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); + }); + }); +}); diff --git a/projects/aca-content/src/lib/utils/aca-search-utils.ts b/projects/aca-content/src/lib/utils/aca-search-utils.ts new file mode 100644 index 0000000000..ced2f3a528 --- /dev/null +++ b/projects/aca-content/src/lib/utils/aca-search-utils.ts @@ -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 . + */ + +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; +} diff --git a/projects/aca-content/src/public-api.ts b/projects/aca-content/src/public-api.ts index 9b98a1b257..21a0df3aa7 100644 --- a/projects/aca-content/src/public-api.ts +++ b/projects/aca-content/src/public-api.ts @@ -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';