mirror of
https://github.com/Alfresco/alfresco-content-app.git
synced 2025-09-17 14:21:14 +00:00
[ACS-8751] Adapt search results to handle query encoding and state propagation
This commit is contained in:
36
package-lock.json
generated
36
package-lock.json
generated
@@ -32,6 +32,7 @@
|
|||||||
"@ngrx/store": "^15.2.0",
|
"@ngrx/store": "^15.2.0",
|
||||||
"@ngrx/store-devtools": "^15.2.0",
|
"@ngrx/store-devtools": "^15.2.0",
|
||||||
"@ngx-translate/core": "^14.0.0",
|
"@ngx-translate/core": "^14.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"material-icons": "^1.13.12",
|
"material-icons": "^1.13.12",
|
||||||
"minimatch-browser": "^1.0.0",
|
"minimatch-browser": "^1.0.0",
|
||||||
@@ -12362,7 +12363,6 @@
|
|||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -12471,6 +12471,30 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"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": {
|
"node_modules/blocking-proxy": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/blocking-proxy/-/blocking-proxy-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/blocking-proxy/-/blocking-proxy-1.0.1.tgz",
|
||||||
@@ -12652,10 +12676,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "5.7.1",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -12672,7 +12695,7 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.3.1",
|
"base64-js": "^1.3.1",
|
||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
@@ -18408,7 +18431,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
@@ -53,6 +53,7 @@
|
|||||||
"@ngrx/store": "^15.2.0",
|
"@ngrx/store": "^15.2.0",
|
||||||
"@ngrx/store-devtools": "^15.2.0",
|
"@ngrx/store-devtools": "^15.2.0",
|
||||||
"@ngx-translate/core": "^14.0.0",
|
"@ngx-translate/core": "^14.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"material-icons": "^1.13.12",
|
"material-icons": "^1.13.12",
|
||||||
"minimatch-browser": "^1.0.0",
|
"minimatch-browser": "^1.0.0",
|
||||||
|
@@ -32,15 +32,18 @@ import { AppHookService, AppService } from '@alfresco/aca-shared';
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
|
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
|
||||||
import { SearchNavigationService } from '../search-navigation.service';
|
import { SearchNavigationService } from '../search-navigation.service';
|
||||||
import { BehaviorSubject, Subject } from 'rxjs';
|
import { BehaviorSubject, of, Subject } from 'rxjs';
|
||||||
import { NotificationService } from '@alfresco/adf-core';
|
import { NotificationService } from '@alfresco/adf-core';
|
||||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
describe('SearchInputComponent', () => {
|
describe('SearchInputComponent', () => {
|
||||||
let fixture: ComponentFixture<SearchInputComponent>;
|
let fixture: ComponentFixture<SearchInputComponent>;
|
||||||
let component: SearchInputComponent;
|
let component: SearchInputComponent;
|
||||||
let actions$: Actions;
|
let actions$: Actions;
|
||||||
let appHookService: AppHookService;
|
let appHookService: AppHookService;
|
||||||
|
let route: ActivatedRoute;
|
||||||
let searchInputService: SearchNavigationService;
|
let searchInputService: SearchNavigationService;
|
||||||
let showErrorSpy: jasmine.Spy;
|
let showErrorSpy: jasmine.Spy;
|
||||||
|
|
||||||
@@ -50,6 +53,10 @@ describe('SearchInputComponent', () => {
|
|||||||
toggleAppNavBar$: new Subject()
|
toggleAppNavBar$: new Subject()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const encodeQuery = (query: any): string => {
|
||||||
|
return Buffer.from(JSON.stringify(query)).toString('base64');
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
appServiceMock.setAppNavbarMode.calls.reset();
|
appServiceMock.setAppNavbarMode.calls.reset();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -68,6 +75,7 @@ describe('SearchInputComponent', () => {
|
|||||||
fixture = TestBed.createComponent(SearchInputComponent);
|
fixture = TestBed.createComponent(SearchInputComponent);
|
||||||
appHookService = TestBed.inject(AppHookService);
|
appHookService = TestBed.inject(AppHookService);
|
||||||
searchInputService = TestBed.inject(SearchNavigationService);
|
searchInputService = TestBed.inject(SearchNavigationService);
|
||||||
|
route = TestBed.inject(ActivatedRoute);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
|
|
||||||
const notificationService = TestBed.inject(NotificationService);
|
const notificationService = TestBed.inject(NotificationService);
|
||||||
@@ -147,35 +155,10 @@ describe('SearchInputComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('onSearchChange()', () => {
|
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 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);
|
component.onSearchChange(searchedTerm);
|
||||||
|
expect(component.searchedWord).toBe(searchedTerm);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show snack for empty search', () => {
|
it('should show snack for empty search', () => {
|
||||||
@@ -246,4 +229,14 @@ describe('SearchInputComponent', () => {
|
|||||||
|
|
||||||
expect(appServiceMock.setAppNavbarMode).toHaveBeenCalledWith('expanded');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -28,10 +28,10 @@ import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
|
|||||||
import { AppConfigService, NotificationService } from '@alfresco/adf-core';
|
import { AppConfigService, NotificationService } from '@alfresco/adf-core';
|
||||||
import { Component, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
import { Component, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||||
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
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 { Store } from '@ngrx/store';
|
||||||
import { Subject } from 'rxjs';
|
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 { SearchInputControlComponent } from '../search-input-control/search-input-control.component';
|
||||||
import { SearchNavigationService } from '../search-navigation.service';
|
import { SearchNavigationService } from '../search-navigation.service';
|
||||||
import { SearchLibrariesQueryBuilderService } from '../search-libraries-results/search-libraries-query-builder.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 { A11yModule } from '@angular/cdk/a11y';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -70,9 +71,6 @@ export class SearchInputComponent implements OnInit, OnDestroy {
|
|||||||
private notificationService = inject(NotificationService);
|
private notificationService = inject(NotificationService);
|
||||||
|
|
||||||
onDestroy$: Subject<boolean> = new Subject<boolean>();
|
onDestroy$: Subject<boolean> = new Subject<boolean>();
|
||||||
hasOneChange = false;
|
|
||||||
hasNewChange = false;
|
|
||||||
navigationTimer: any;
|
|
||||||
has400LibraryError = false;
|
has400LibraryError = false;
|
||||||
hasLibrariesConstraint = false;
|
hasLibrariesConstraint = false;
|
||||||
searchOnChange: boolean;
|
searchOnChange: boolean;
|
||||||
@@ -110,6 +108,7 @@ export class SearchInputComponent implements OnInit, OnDestroy {
|
|||||||
private queryLibrariesBuilder: SearchLibrariesQueryBuilderService,
|
private queryLibrariesBuilder: SearchLibrariesQueryBuilderService,
|
||||||
private config: AppConfigService,
|
private config: AppConfigService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private route: ActivatedRoute,
|
||||||
private store: Store<AppStore>,
|
private store: Store<AppStore>,
|
||||||
private appHookService: AppHookService,
|
private appHookService: AppHookService,
|
||||||
private appService: AppService,
|
private appService: AppService,
|
||||||
@@ -121,14 +120,14 @@ export class SearchInputComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.showInputValue();
|
this.showInputValue();
|
||||||
|
|
||||||
this.router.events
|
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
|
||||||
.pipe(takeUntil(this.onDestroy$))
|
const encodedQuery = params['q'] ? params['q'] : null;
|
||||||
.pipe(filter((e) => e instanceof RouterEvent))
|
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
|
||||||
.subscribe((event) => {
|
|
||||||
if (event instanceof NavigationEnd) {
|
if (this.searchInputControl) {
|
||||||
this.showInputValue();
|
this.searchInputControl.searchTerm = this.searchedWord;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.appHookService.library400Error.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
|
this.appHookService.library400Error.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
|
||||||
this.has400LibraryError = true;
|
this.has400LibraryError = true;
|
||||||
@@ -192,24 +191,6 @@ export class SearchInputComponent implements OnInit, OnDestroy {
|
|||||||
this.has400LibraryError = false;
|
this.has400LibraryError = false;
|
||||||
this.hasLibrariesConstraint = this.evaluateLibrariesConstraint();
|
this.hasLibrariesConstraint = this.evaluateLibrariesConstraint();
|
||||||
this.searchedWord = searchTerm;
|
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() {
|
searchByOption() {
|
||||||
@@ -305,7 +286,7 @@ export class SearchInputComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (urlSegmentGroup) {
|
if (urlSegmentGroup) {
|
||||||
const urlSegments: UrlSegment[] = urlSegmentGroup.segments;
|
const urlSegments: UrlSegment[] = urlSegmentGroup.segments;
|
||||||
searchTerm = urlSegments[0].parameters['q'] ? decodeURIComponent(urlSegments[0].parameters['q']) : '';
|
searchTerm = extractSearchedWordFromEncodedQuery(urlSegments[0].parameters['q']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -27,13 +27,16 @@ import { AppTestingModule } from '../../../testing/app-testing.module';
|
|||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { SearchLibrariesResultsComponent } from './search-libraries-results.component';
|
import { SearchLibrariesResultsComponent } from './search-libraries-results.component';
|
||||||
import { SearchLibrariesQueryBuilderService } from './search-libraries-query-builder.service';
|
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 { AppService } from '@alfresco/aca-shared';
|
||||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
describe('SearchLibrariesResultsComponent', () => {
|
describe('SearchLibrariesResultsComponent', () => {
|
||||||
let component: SearchLibrariesResultsComponent;
|
let component: SearchLibrariesResultsComponent;
|
||||||
let fixture: ComponentFixture<SearchLibrariesResultsComponent>;
|
let fixture: ComponentFixture<SearchLibrariesResultsComponent>;
|
||||||
|
let route: ActivatedRoute;
|
||||||
|
|
||||||
const emptyPage = { list: { pagination: { totalItems: 0 }, entries: [] } };
|
const emptyPage = { list: { pagination: { totalItems: 0 }, entries: [] } };
|
||||||
const appServiceMock = {
|
const appServiceMock = {
|
||||||
@@ -42,6 +45,10 @@ describe('SearchLibrariesResultsComponent', () => {
|
|||||||
setAppNavbarMode: jasmine.createSpy('setAppNavbarMode')
|
setAppNavbarMode: jasmine.createSpy('setAppNavbarMode')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const encodeQuery = (query: any): string => {
|
||||||
|
return Buffer.from(JSON.stringify(query)).toString('base64');
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
appServiceMock.setAppNavbarMode.calls.reset();
|
appServiceMock.setAppNavbarMode.calls.reset();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -56,6 +63,7 @@ describe('SearchLibrariesResultsComponent', () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
route = TestBed.inject(ActivatedRoute);
|
||||||
fixture = TestBed.createComponent(SearchLibrariesResultsComponent);
|
fixture = TestBed.createComponent(SearchLibrariesResultsComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
});
|
});
|
||||||
@@ -72,4 +80,14 @@ describe('SearchLibrariesResultsComponent', () => {
|
|||||||
|
|
||||||
expect(appServiceMock.setAppNavbarMode).toHaveBeenCalledWith('collapsed');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -45,6 +45,8 @@ import { CustomEmptyContentTemplateDirective, DataColumnComponent, DataColumnLis
|
|||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { DocumentListDirective } from '../../../directives/document-list.directive';
|
import { DocumentListDirective } from '../../../directives/document-list.directive';
|
||||||
import { DocumentListComponent } from '@alfresco/adf-content-services';
|
import { DocumentListComponent } from '@alfresco/adf-content-services';
|
||||||
|
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -128,14 +130,12 @@ export class SearchLibrariesResultsComponent extends PageComponent implements On
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (this.route) {
|
if (this.route) {
|
||||||
this.route.params.forEach((params: Params) => {
|
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
|
||||||
// eslint-disable-next-line no-prototype-builtins
|
const encodedQuery = params[this.queryParamName] ? params[this.queryParamName] : null;
|
||||||
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
|
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
|
||||||
const query = this.formatSearchQuery(this.searchedWord);
|
if (this.searchedWord?.length > 1) {
|
||||||
|
|
||||||
if (query && query.length > 1) {
|
|
||||||
this.librariesQueryBuilder.paging.skipCount = 0;
|
this.librariesQueryBuilder.paging.skipCount = 0;
|
||||||
this.librariesQueryBuilder.userQuery = query;
|
this.librariesQueryBuilder.userQuery = this.searchedWord;
|
||||||
this.librariesQueryBuilder.update();
|
this.librariesQueryBuilder.update();
|
||||||
} else {
|
} else {
|
||||||
this.librariesQueryBuilder.userQuery = null;
|
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) {
|
onSearchResultLoaded(nodePaging: NodePaging) {
|
||||||
this.data = nodePaging;
|
this.data = nodePaging;
|
||||||
this.totalResults = this.getNumberOfResults();
|
this.totalResults = this.getNumberOfResults();
|
||||||
|
@@ -30,10 +30,11 @@ import { NavigateToFolder } from '@alfresco/aca-shared/store';
|
|||||||
import { Pagination, SearchRequest } from '@alfresco/js-api';
|
import { Pagination, SearchRequest } from '@alfresco/js-api';
|
||||||
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
|
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
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 { AppTestingModule } from '../../../testing/app-testing.module';
|
||||||
import { AppService } from '@alfresco/aca-shared';
|
import { AppService } from '@alfresco/aca-shared';
|
||||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
describe('SearchComponent', () => {
|
describe('SearchComponent', () => {
|
||||||
let component: SearchResultsComponent;
|
let component: SearchResultsComponent;
|
||||||
@@ -43,10 +44,15 @@ describe('SearchComponent', () => {
|
|||||||
let queryBuilder: SearchQueryBuilderService;
|
let queryBuilder: SearchQueryBuilderService;
|
||||||
let translate: TranslationService;
|
let translate: TranslationService;
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
let route: ActivatedRoute;
|
||||||
const searchRequest = {} as SearchRequest;
|
const searchRequest = {} as SearchRequest;
|
||||||
let params: BehaviorSubject<any>;
|
let params: BehaviorSubject<any>;
|
||||||
let showErrorSpy: jasmine.Spy;
|
let showErrorSpy: jasmine.Spy;
|
||||||
|
|
||||||
|
const encodeQuery = (query: any): string => {
|
||||||
|
return Buffer.from(JSON.stringify(query)).toString('base64');
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' });
|
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' });
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -79,6 +85,8 @@ describe('SearchComponent', () => {
|
|||||||
queryBuilder = TestBed.inject(SearchQueryBuilderService);
|
queryBuilder = TestBed.inject(SearchQueryBuilderService);
|
||||||
translate = TestBed.inject(TranslationService);
|
translate = TestBed.inject(TranslationService);
|
||||||
router = TestBed.inject(Router);
|
router = TestBed.inject(Router);
|
||||||
|
route = TestBed.inject(ActivatedRoute);
|
||||||
|
route.queryParams = of({});
|
||||||
|
|
||||||
const notificationService = TestBed.inject(NotificationService);
|
const notificationService = TestBed.inject(NotificationService);
|
||||||
showErrorSpy = spyOn(notificationService, 'showError');
|
showErrorSpy = spyOn(notificationService, 'showError');
|
||||||
@@ -149,79 +157,6 @@ describe('SearchComponent', () => {
|
|||||||
expect(showErrorSpy).toHaveBeenCalledWith('Generic Error');
|
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', () => {
|
it('should navigate to folder on double click', () => {
|
||||||
const node: any = {
|
const node: any = {
|
||||||
entry: {
|
entry: {
|
||||||
@@ -267,16 +202,26 @@ describe('SearchComponent', () => {
|
|||||||
expect(queryBuilder.update).toHaveBeenCalled();
|
expect(queryBuilder.update).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update the user query whenever param changed', () => {
|
it('should update the user query, populate filters state and execute query whenever param changed', (done) => {
|
||||||
params.next({ q: '=orange' });
|
spyOn(queryBuilder.populateFilters, 'next');
|
||||||
expect(queryBuilder.userQuery).toBe(`((=cm:name:"orange"))`);
|
spyOn(queryBuilder, 'execute');
|
||||||
expect(queryBuilder.update).toHaveBeenCalled();
|
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', () => {
|
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);
|
queryBuilder.configUpdated.next({ 'app:fields': ['cm:tag'] } as any);
|
||||||
expect(queryBuilder.userQuery).toBe(`((=cm:tag:"orange"))`);
|
expect(queryBuilder.userQuery).toBe(`((cm:tag:"orange*"))`);
|
||||||
expect(queryBuilder.update).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -52,7 +52,6 @@ import {
|
|||||||
TranslationService,
|
TranslationService,
|
||||||
ViewerToolbarComponent
|
ViewerToolbarComponent
|
||||||
} from '@alfresco/adf-core';
|
} from '@alfresco/adf-core';
|
||||||
import { combineLatest } from 'rxjs';
|
|
||||||
import {
|
import {
|
||||||
ContextActionsDirective,
|
ContextActionsDirective,
|
||||||
InfoDrawerComponent,
|
InfoDrawerComponent,
|
||||||
@@ -78,6 +77,12 @@ import { SearchResultsRowComponent } from '../search-results-row/search-results-
|
|||||||
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
|
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
|
||||||
import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-actions-dropdown.component';
|
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 { 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({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -148,14 +153,13 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
|
|||||||
maxItems: 25
|
maxItems: 25
|
||||||
};
|
};
|
||||||
|
|
||||||
combineLatest([this.route.params, this.queryBuilder.configUpdated])
|
this.queryBuilder.configUpdated
|
||||||
|
.asObservable()
|
||||||
.pipe(takeUntil(this.onDestroy$))
|
.pipe(takeUntil(this.onDestroy$))
|
||||||
.subscribe(([params, searchConfig]) => {
|
.subscribe((searchConfig) => {
|
||||||
// eslint-disable-next-line no-prototype-builtins
|
const updatedUserQuery = formatSearchTerm(this.searchedWord, searchConfig['app:fields']);
|
||||||
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
|
if (updatedUserQuery) {
|
||||||
const query = this.formatSearchQuery(this.searchedWord, searchConfig['app:fields']);
|
this.queryBuilder.userQuery = updatedUserQuery;
|
||||||
if (query) {
|
|
||||||
this.queryBuilder.userQuery = decodeURIComponent(query);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -189,17 +193,26 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
|
|||||||
this.columns = this.extensions.documentListPresets.searchResults || [];
|
this.columns = this.extensions.documentListPresets.searchResults || [];
|
||||||
|
|
||||||
if (this.route) {
|
if (this.route) {
|
||||||
this.route.params.forEach((params: Params) => {
|
this.route.queryParams.pipe(takeUntil(this.onDestroy$)).subscribe((params: Params) => {
|
||||||
// eslint-disable-next-line no-prototype-builtins
|
const encodedQuery = params[this.queryParamName] ? params[this.queryParamName] : null;
|
||||||
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
|
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
|
||||||
if (this.searchedWord) {
|
const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(encodedQuery);
|
||||||
this.queryBuilder.update();
|
if (filtersFromEncodedQuery !== null) {
|
||||||
} else {
|
const filtersToLoad = Object.keys(filtersFromEncodedQuery).length;
|
||||||
this.queryBuilder.userQuery = null;
|
let loadedFilters = this.searchedWord === '' ? 0 : 1;
|
||||||
this.queryBuilder.executed.next({
|
this.queryBuilder.filterLoaded
|
||||||
list: { pagination: { totalItems: 0 }, entries: [] }
|
.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);
|
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) {
|
onSearchResultLoaded(nodePaging: ResultSetPaging) {
|
||||||
this.data = nodePaging;
|
this.data = nodePaging;
|
||||||
this.totalResults = this.getNumberOfResults();
|
this.totalResults = this.getNumberOfResults();
|
||||||
|
@@ -29,10 +29,12 @@ import { EffectsModule } from '@ngrx/effects';
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { SearchOptionIds, SearchByTermAction, SearchAction } from '@alfresco/aca-shared/store';
|
import { SearchOptionIds, SearchByTermAction, SearchAction } from '@alfresco/aca-shared/store';
|
||||||
|
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
|
||||||
|
|
||||||
describe('SearchEffects', () => {
|
describe('SearchEffects', () => {
|
||||||
let store: Store<any>;
|
let store: Store<any>;
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
let queryBuilder: SearchQueryBuilderService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -41,18 +43,21 @@ describe('SearchEffects', () => {
|
|||||||
|
|
||||||
store = TestBed.inject(Store);
|
store = TestBed.inject(Store);
|
||||||
router = TestBed.inject(Router);
|
router = TestBed.inject(Router);
|
||||||
|
queryBuilder = TestBed.inject(SearchQueryBuilderService);
|
||||||
|
|
||||||
spyOn(router, 'navigateByUrl').and.stub();
|
spyOn(router, 'navigateByUrl').and.stub();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('searchByTerm$', () => {
|
describe('searchByTerm$', () => {
|
||||||
it('should navigate to `search` when search options has library false', fakeAsync(() => {
|
it('should navigate to `search` when search options has library false', fakeAsync(() => {
|
||||||
|
spyOn(queryBuilder, 'navigateToSearch');
|
||||||
store.dispatch(new SearchByTermAction('test', []));
|
store.dispatch(new SearchByTermAction('test', []));
|
||||||
tick();
|
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(() => {
|
it('should navigate to `search-libraries` when search options has library true', fakeAsync(() => {
|
||||||
|
spyOn(queryBuilder, 'navigateToSearch');
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
new SearchByTermAction('test', [
|
new SearchByTermAction('test', [
|
||||||
{
|
{
|
||||||
@@ -66,23 +71,7 @@ describe('SearchEffects', () => {
|
|||||||
|
|
||||||
tick();
|
tick();
|
||||||
|
|
||||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/search-libraries;q=test');
|
expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('(cm:name:"test*")', '/search-libraries');
|
||||||
}));
|
|
||||||
|
|
||||||
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');
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -23,15 +23,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Actions, ofType, createEffect } from '@ngrx/effects';
|
import { Actions, ofType, createEffect } from '@ngrx/effects';
|
||||||
import { Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { SearchAction, SearchActionTypes, SearchByTermAction, SearchOptionIds } from '@alfresco/aca-shared/store';
|
import { SearchAction, SearchActionTypes, SearchByTermAction, SearchOptionIds } from '@alfresco/aca-shared/store';
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { SearchNavigationService } from '../../components/search/search-navigation.service';
|
import { SearchNavigationService } from '../../components/search/search-navigation.service';
|
||||||
|
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
|
||||||
|
import { formatSearchTerm } from '../../utils/aca-search-utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchEffects {
|
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(
|
search$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
@@ -49,14 +52,13 @@ export class SearchEffects {
|
|||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
|
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
|
||||||
map((action) => {
|
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 libItem = action.searchOptions.find((item) => item.id === SearchOptionIds.Libraries);
|
||||||
const librarySelected = !!libItem && libItem.value;
|
const librarySelected = !!libItem && libItem.value;
|
||||||
if (librarySelected) {
|
if (librarySelected) {
|
||||||
this.router.navigateByUrl('/search-libraries;q=' + encodeURIComponent(query));
|
this.queryBuilder.navigateToSearch(query, '/search-libraries');
|
||||||
} else {
|
} else {
|
||||||
this.router.navigateByUrl('/search;q=' + encodeURIComponent(query));
|
this.queryBuilder.navigateToSearch(query, '/search');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
155
projects/aca-content/src/lib/utils/aca-search-utils.spec.ts
Normal file
155
projects/aca-content/src/lib/utils/aca-search-utils.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
144
projects/aca-content/src/lib/utils/aca-search-utils.ts
Normal file
144
projects/aca-content/src/lib/utils/aca-search-utils.ts
Normal 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;
|
||||||
|
}
|
@@ -31,3 +31,4 @@ export * from './lib/aca-content.routes';
|
|||||||
export * from './lib/extensions/core.extensions.module';
|
export * from './lib/extensions/core.extensions.module';
|
||||||
export * from './lib/store/initial-state';
|
export * from './lib/store/initial-state';
|
||||||
export * from './lib/services/content-url.service';
|
export * from './lib/services/content-url.service';
|
||||||
|
export * from './lib/utils/aca-search-utils';
|
||||||
|
Reference in New Issue
Block a user