[MNT-25681] Search refactoring and unification (#5257)

* [MNT-25681] Search refactoring and unification

* [MNT-25681] CR fixes

* [MNT-25681] ADF version bump

* [MNT-25681] Two way binding on search input

* MNT-25681] search e2es fixes

* [MNT-25681] search e2es stabilization pt 2

* [MNT-25149] Add unit tests for multivalued select parameter default value (#5262)

* [MNT-25149] Add unit tests for multivalued select parameter default value

* address comment on pr

* [MNT-25681] Fix content node selector panel issues

* [PRODSEC-13253] Upgraded angular version to fix vulnerability and to have the same versions like ADF

* [MNT-25681] Unit test fix

* [MNT-25681] Search input fix

---------

Co-authored-by: Adam Świderski <adam.tomasz.swiderski@gmail.com>
Co-authored-by: Akash Rathod <41251473+akashrathod28@users.noreply.github.com>
Co-authored-by: Aleksander Sklorz <Aleksander.Sklorz@hyland.com>
This commit is contained in:
Michal Kinas
2026-07-06 11:08:15 +02:00
committed by GitHub
co-authored by Adam Świderski Akash Rathod Aleksander Sklorz
parent e2df1e352c
commit 1695f17b7c
34 changed files with 6884 additions and 6769 deletions
+2 -1
View File
@@ -330,5 +330,6 @@
"downloadPromptReminderDelay": 30, "downloadPromptReminderDelay": 30,
"enableFileAutoDownload": true, "enableFileAutoDownload": true,
"fileAutoDownloadSizeThresholdInMB": 15 "fileAutoDownloadSizeThresholdInMB": 15
} },
"search-wildcards-enabled": true
} }
+49 -17
View File
@@ -53,14 +53,31 @@ in the Content Application when using **Search Input** component.
> **Partial** support means the feature supports basic scenarios > **Partial** support means the feature supports basic scenarios
> and there are edge cases that are not yet fully tested and might not work. > and there are edge cases that are not yet fully tested and might not work.
## Search Modes
The **Search Input** component supports two search modes. You can switch between them using the mode toggle displayed next to the search box:
- **Standard search** (default) — finds text exactly as you enter it. The application builds the query for you, matching your input against the fields configured for the active search form (see [Search Forms](/features/search-forms)). This is the recommended mode for everyday searching.
- **Formula search** — lets you build a query manually using
[Alfresco Full Text Search](https://support.hyland.com/r/Alfresco/Alfresco-Search-Services/2.0/Alfresco-Search-Services/Using/Full-text-search-reference) (FTS) syntax.
In this mode the application passes your input to the search service unchanged, so special characters such as `:`, `"`, `*` and the `AND`/`OR` operators are interpreted as search syntax.
The selected mode is preserved in the search URL, so it is restored when you reload the page or share a search link.
> In previous versions the application implicitly switched to raw query handling whenever it detected a `:` or `"` character in the input, and used a leading `=` symbol for exact-term matching. That implicit behavior has been removed in favor of the explicit **Formula search** mode. To run field-scoped queries, phrase queries, exact-term matching, or any other FTS syntax, switch to **Formula search**.
## Search Queries and Precise Searching ## Search Queries and Precise Searching
You can customize the queries to get better results. When using **Standard search**, the application constructs the query from your input. Given a single term, the default query matches that term against every field configured for the active search form:
Given that, no colon ":" suffixes the term, then the default query is constructed for text searches. The default query is:
```text ```text
(cm:name:"[term]*" OR cm:title:"[term]*" OR cm:description:"[term]*" OR TEXT:"[term]*" OR TAG:"[term]*") ((cm:name:"[term]" OR cm:title:"[term]" OR cm:description:"[term]" OR TEXT:"[term]" OR TAG:"[term]"))
```
When [wildcard searching](#wildcard-searching) is enabled, a `*` suffix is appended to every term so that partial matches are also returned:
```text
((cm:name:"[term]*" OR cm:title:"[term]*" OR cm:description:"[term]*" OR TEXT:"[term]*" OR TAG:"[term]*"))
``` ```
Note that compared to Share the following defaults are removed from ACA: Note that compared to Share the following defaults are removed from ACA:
@@ -73,23 +90,38 @@ OR ia:whatEvent:"[term]*" OR ia:descriptionEvent:"[term]*" OR lnk:title:"[term]*
1. If you have entered more than one word into the search input box, then the search query is constructed automatically using an `AND` operation. 1. If you have entered more than one word into the search input box, then the search query is constructed automatically using an `AND` operation.
2. If you have entered more than one word encapsulated in quotation marks, then the search query is constructed treated everything as a single string. 2. If you have entered more than one word separated by `AND`, then the search query is constructed using an `AND` conjunction. Since `AND` is the default operator (see fact 1), the explicit `AND` keywords are removed when the search input value is processed.
3. If you have entered more than one word separated by `AND`, then the search query is constructed using an `AND` conjunction. Since `AND` is the default operator (see fact 1), the explicit `AND` keywords are removed when the search input value is processed. 3. If you have entered more than one word separated by `OR`, then the search query is constructed using an `OR` disjunction. Unlike `AND`, the `OR` operators are preserved when processing the search input value because `OR` is not the default operator.
4. If you have entered more than one word separated by `OR`, then the search query is constructed using an `OR` disjunction. Unlike `AND`, the `OR` operators are preserved when processing the search input value because `OR` is not the default operator. 4. For phrase queries, exact-term matching, field-scoped queries, or any other advanced FTS syntax, switch to **Formula search** so that the input is sent to the search service unchanged.
5. If you have entered an `=` symbol before the search term, then the search query is constructed using exact term matching. **Note:** Works only with Solr search. For Elastic Search consider using Search Logical Filter.
### Examples ### Examples
| Search Type | Entered search input value | Expected result | Processed search input value | | Search Type | Search mode | Entered search input value | Expected result |
| ----------- | -------------------------- | -------------------------------------------------------------------------------- | ---------------------------- | | ---------------------- | ----------- | ------------------------------- | -------------------------------------------------------------------------------- |
| Single Term | banana | Nodes that contain the term **banana** in any content | banana | | Single Term | Standard | banana | Nodes that contain the term **banana** in any configured field |
| Conjunction | big yellow banana | Nodes that contain all of the terms **big**, **yellow**, and **banana** | big yellow banana | | Conjunction | Standard | big yellow banana | Nodes that contain all of the terms **big**, **yellow**, and **banana** |
| Phrase | "big yellow banana" | Nodes that contain the exact phrase **big yellow banana** | "big yellow banana" | | Conjunction | Standard | big AND yellow AND banana | Nodes that contain all of the terms **big**, **yellow**, and **banana** |
| Conjunction | big AND yellow AND banana | Nodes that contain all of the terms **big**, **yellow**, and **banana** | big yellow banana | | Disjunction | Standard | orange OR banana OR apple | Nodes that contain at least one of the terms **orange**, **banana** or **apple** |
| Disjunction | orange OR banana OR apple | Nodes that contain at least one of the terms **orange**, **banana** or **apple** | orange OR banana OR apple | | Phrase | Formula | cm:name:"big yellow banana" | Nodes whose `cm:name` contains the exact phrase **big yellow banana** |
| Exact term | =orange | Nodes that contain the exact term **orange** in any content. | orange | | Field-scoped / advanced| Formula | TEXT:"orange" AND TAG:"fruit" | The query is passed to the search service exactly as entered |
### Wildcard searching
Wildcard searching is controlled by the `search-wildcards-enabled` property in `app.config.json`:
```json
{
"search-wildcards-enabled": true
}
```
| Value | Behavior |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `true` | A `*` suffix is appended to every term in **Standard search**, so partial matches are returned (e.g. `ban` matches `banana`). |
| `false` | Terms are matched as entered, without an implicit trailing wildcard. |
This setting only affects how **Standard search** builds the query; in **Formula search** you control wildcards yourself by typing them into the query.
**Important note:** Consider using Search Logical Filter when you need to combine multiple search types. Mixing search types directly in the input may result in wrong query format and incorrect results. **Important note:** Consider using Search Logical Filter when you need to combine multiple search types. Mixing search types directly in the input may result in wrong query format and incorrect results.
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"all": { "all": {
"XAT-17697": "https://hyland.atlassian.net/browse/ACS-7464" "XAT-17697": "https://hyland.atlassian.net/browse/ACS-7464",
"XAT-17121": "https://hyland.atlassian.net/browse/ACS-12165"
} }
} }
@@ -119,7 +119,7 @@ test.describe('Search sorting', () => {
if (searchTerm) { if (searchTerm) {
await searchPage.searchWithin(searchTerm, 'files'); await searchPage.searchWithin(searchTerm, 'files');
} else { } else {
await searchPage.searchWithin(`TEXT:${random}`, 'files'); await searchPage.searchWithin(`*${random}`, 'files');
} }
await searchPage.searchSortingPicker.sortBy(sortBy, sortOrder); await searchPage.searchSortingPicker.sortBy(sortBy, sortOrder);
await searchPage.dataTable.spinnerWaitForReload(); await searchPage.dataTable.spinnerWaitForReload();
+6470 -6394
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -42,22 +42,22 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@alfresco/adf-content-services": "9.1.0-28253465549", "@alfresco/adf-content-services": "9.1.0-28667499581",
"@alfresco/adf-core": "9.1.0-28253465549", "@alfresco/adf-core": "9.1.0-28667499581",
"@alfresco/adf-extensions": "9.1.0-28253465549", "@alfresco/adf-extensions": "9.1.0-28667499581",
"@alfresco/eslint-plugin-eslint-angular": "9.1.0-28253465549", "@alfresco/eslint-plugin-eslint-angular": "9.1.0-28667499581",
"@alfresco/js-api": "10.1.0-28253465549", "@alfresco/js-api": "10.1.0-28667499581",
"@angular/animations": "20.3.9", "@angular/animations": "20.3.25",
"@angular/cdk": "20.2.14", "@angular/cdk": "20.2.14",
"@angular/common": "20.3.9", "@angular/common": "20.3.25",
"@angular/compiler": "20.3.9", "@angular/compiler": "20.3.25",
"@angular/core": "20.3.9", "@angular/core": "20.3.25",
"@angular/forms": "20.3.9", "@angular/forms": "20.3.25",
"@angular/material": "20.2.14", "@angular/material": "20.2.14",
"@angular/material-date-fns-adapter": "20.2.14", "@angular/material-date-fns-adapter": "20.2.14",
"@angular/platform-browser": "20.3.9", "@angular/platform-browser": "20.3.25",
"@angular/platform-browser-dynamic": "20.3.9", "@angular/platform-browser-dynamic": "20.3.25",
"@angular/router": "20.3.9", "@angular/router": "20.3.25",
"@fontsource/open-sans": "^5.1.0", "@fontsource/open-sans": "^5.1.0",
"@mat-datetimepicker/core": "16.0.1", "@mat-datetimepicker/core": "16.0.1",
"@ngrx/effects": "~20.1.0", "@ngrx/effects": "~20.1.0",
@@ -83,7 +83,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@alfresco/adf-cli": "9.1.0-28253465549", "@alfresco/adf-cli": "9.1.0-28667499581",
"@angular-devkit/build-angular": "20.3.30", "@angular-devkit/build-angular": "20.3.30",
"@angular-devkit/core": "20.3.16", "@angular-devkit/core": "20.3.16",
"@angular-devkit/schematics": "20.3.16", "@angular-devkit/schematics": "20.3.16",
@@ -92,8 +92,8 @@
"@angular-eslint/eslint-plugin-template": "20.7.0", "@angular-eslint/eslint-plugin-template": "20.7.0",
"@angular-eslint/schematics": "20.7.0", "@angular-eslint/schematics": "20.7.0",
"@angular-eslint/template-parser": "20.7.0", "@angular-eslint/template-parser": "20.7.0",
"@angular/compiler-cli": "20.3.9", "@angular/compiler-cli": "20.3.25",
"@angular/language-service": "20.3.9", "@angular/language-service": "20.3.25",
"@cspell/eslint-plugin": "^10.0.1", "@cspell/eslint-plugin": "^10.0.1",
"@nx/angular": "22.7.6", "@nx/angular": "22.7.6",
"@nx/eslint-plugin": "22.5.4", "@nx/eslint-plugin": "22.5.4",
@@ -1770,7 +1770,6 @@
} }
] ]
}, },
"aca:triggeredOnChange": false,
"resetButton": true, "resetButton": true,
"filterQueries": [ "filterQueries": [
{ "query": "+TYPE:'cm:folder' OR +TYPE:'cm:content'" }, { "query": "+TYPE:'cm:folder' OR +TYPE:'cm:content'" },
@@ -1991,7 +1990,6 @@
} }
] ]
}, },
"aca:triggeredOnChange": false,
"resetButton": true, "resetButton": true,
"filterQueries": [ "filterQueries": [
{ "query": "+TYPE:'cm:folder' OR +TYPE:'cm:content' AND +ASPECT:'cm:dublincore'" }, { "query": "+TYPE:'cm:folder' OR +TYPE:'cm:content' AND +ASPECT:'cm:dublincore'" },
@@ -2167,7 +2165,6 @@
} }
] ]
}, },
"aca:triggeredOnChange": false,
"resetButton": true, "resetButton": true,
"filterQueries": [ "filterQueries": [
{ "query": "+TYPE:'cm:folder' OR +TYPE:'cm:content' AND +ASPECT:'cm:effectivity'" }, { "query": "+TYPE:'cm:folder' OR +TYPE:'cm:content' AND +ASPECT:'cm:effectivity'" },
+4 -2
View File
@@ -623,11 +623,13 @@
"MIN_LENGTH": "Search input must have at least 2 alphanumeric characters.", "MIN_LENGTH": "Search input must have at least 2 alphanumeric characters.",
"REQUIRED": "Search input is required.", "REQUIRED": "Search input is required.",
"WHITESPACE": "Search input cannot be only whitespace.", "WHITESPACE": "Search input cannot be only whitespace.",
"OPERATORS": "Search input cannot begin with, end with or contain only operators.",
"IN_PREFIX": "In", "IN_PREFIX": "In",
"FILES_AND_FOLDERS": "Files and folders", "FILES_AND_FOLDERS": "Files and folders",
"RESET": "Reset", "RESET": "Reset",
"SEARCH_IN": "Search in" "SEARCH_IN": "Search in",
"SEARCH_MODE_TOGGLE_DESCRIPTION": "Switch search mode.\n\nRegular search: Find text as you enter it.\n\nFormula search: Use special characters to build a query. The system reads symbols as search syntax.",
"SEARCH_MODE_TOGGLE_DESCRIPTION_REGULAR": "Regular search",
"SEARCH_MODE_TOGGLE_DESCRIPTION_FORMULA": "Formula search"
}, },
"SORT": { "SORT": {
"SORTING_OPTION": "Sort by", "SORTING_OPTION": "Sort by",
@@ -187,7 +187,8 @@ describe('RuleActionUiComponent', () => {
title: 'ACA_FOLDER_RULES.RULE_DETAILS.PLACEHOLDER.CHOOSE_FOLDER', title: 'ACA_FOLDER_RULES.RULE_DETAILS.PLACEHOLDER.CHOOSE_FOLDER',
actionName: NodeAction.CHOOSE, actionName: NodeAction.CHOOSE,
currentFolderId: component.nodeId, currentFolderId: component.nodeId,
select: jasmine.any(Subject) select: jasmine.any(Subject),
showFilesInResult: false
}; };
const dialogSpy = spyOn(dialog, 'open').and.returnValue({ afterClosed: () => of({}) } as MatDialogRef<any>); const dialogSpy = spyOn(dialog, 'open').and.returnValue({ afterClosed: () => of({}) } as MatDialogRef<any>);
fixture.detectChanges(); fixture.detectChanges();
@@ -361,7 +362,8 @@ describe('RuleActionUiComponent', () => {
title: 'ACA_FOLDER_RULES.RULE_DETAILS.PLACEHOLDER.CHOOSE_FOLDER', title: 'ACA_FOLDER_RULES.RULE_DETAILS.PLACEHOLDER.CHOOSE_FOLDER',
actionName: NodeAction.CHOOSE, actionName: NodeAction.CHOOSE,
currentFolderId: 'test-folder-id', currentFolderId: 'test-folder-id',
select: jasmine.any(Subject) select: jasmine.any(Subject),
showFilesInResult: false
}, },
panelClass: 'adf-content-node-selector-dialog', panelClass: 'adf-content-node-selector-dialog',
width: '630px' width: '630px'
@@ -310,7 +310,8 @@ export class RuleActionUiComponent implements ControlValueAccessor, OnInit, OnCh
title: this.translate.instant('ACA_FOLDER_RULES.RULE_DETAILS.PLACEHOLDER.CHOOSE_FOLDER'), title: this.translate.instant('ACA_FOLDER_RULES.RULE_DETAILS.PLACEHOLDER.CHOOSE_FOLDER'),
actionName: NodeAction.CHOOSE, actionName: NodeAction.CHOOSE,
currentFolderId: this.nodeId, currentFolderId: this.nodeId,
select: new Subject<Node[]>() select: new Subject<Node[]>(),
showFilesInResult: false
}; };
this.dialog.open(ContentNodeSelectorComponent, { this.dialog.open(ContentNodeSelectorComponent, {
@@ -45,7 +45,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { testHeader } from '../../testing/document-base-page-utils'; import { testHeader } from '../../testing/document-base-page-utils';
import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { getCurrentFolder } from '@alfresco/aca-shared/store'; import { getCurrentFolder } from '@alfresco/aca-shared/store';
import { UnitTestingUtils } from '@alfresco/adf-core'; import { ShowHeaderMode, UnitTestingUtils } from '@alfresco/adf-core';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
describe('FilesComponent', () => { describe('FilesComponent', () => {
@@ -496,6 +496,28 @@ describe('FilesComponent', () => {
}); });
}); });
describe('onFilterSelected', () => {
it('should activate the filter header without navigating when filters are selected', () => {
router.navigate['calls'].reset();
component.onFilterSelected([{ key: 'name', value: 'aaa' } as FilterSearch]);
expect(component.isFilterHeaderActive).toBeTrue();
expect(component.showHeader).toBe(ShowHeaderMode.Always);
expect(router.navigate).not.toHaveBeenCalled();
});
it('should deactivate the filter header and navigate to the current route when no filters are selected', () => {
router.navigate['calls'].reset();
component.onFilterSelected([]);
expect(component.isFilterHeaderActive).toBeFalse();
expect(component.showHeader).toBe(ShowHeaderMode.Data);
expect(router.navigate).toHaveBeenCalledWith(['.'], jasmine.objectContaining({ relativeTo: route }));
});
});
describe('isSiteContainer', () => { describe('isSiteContainer', () => {
it('should return false if node has no aspectNames', () => { it('should return false if node has no aspectNames', () => {
const mock: any = { aspectNames: [] }; const mock: any = { aspectNames: [] };
@@ -386,7 +386,6 @@ export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
if (activeFilters.length) { if (activeFilters.length) {
this.showHeader = ShowHeaderMode.Always; this.showHeader = ShowHeaderMode.Always;
this.isFilterHeaderActive = true; this.isFilterHeaderActive = true;
this.navigateToFilter(activeFilters);
} else { } else {
void this.router.navigate(['.'], { relativeTo: this.route }); void this.router.navigate(['.'], { relativeTo: this.route });
this.isFilterHeaderActive = false; this.isFilterHeaderActive = false;
@@ -394,21 +393,6 @@ export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
} }
} }
navigateToFilter(activeFilters: FilterSearch[]) {
const objectFromMap = {};
activeFilters.forEach((filter: FilterSearch) => {
let paramValue;
if (filter?.value?.from && filter?.value?.to) {
paramValue = `${filter.value.from}||${filter.value.to}`;
} else {
paramValue = filter.value;
}
objectFromMap[filter.key] = paramValue;
});
void this.router.navigate([], { relativeTo: this.route, queryParams: objectFromMap });
}
onError(error: HttpErrorResponse) { onError(error: HttpErrorResponse) {
this.isValidPath = false; this.isValidPath = false;
if (this.router.url.includes('libraries')) { if (this.router.url.includes('libraries')) {
@@ -27,7 +27,6 @@ import { Store } from '@ngrx/store';
import { SearchExecutionService } from './search-execution.service'; import { SearchExecutionService } from './search-execution.service';
import { SearchFilterService } from './search-filter.service'; import { SearchFilterService } from './search-filter.service';
import { SearchNavigationService } from './search-navigation.service'; import { SearchNavigationService } from './search-navigation.service';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchLibrariesQueryBuilderService } from './search-libraries-results/search-libraries-query-builder.service'; import { SearchLibrariesQueryBuilderService } from './search-libraries-results/search-libraries-query-builder.service';
import { AppStore, SearchActionTypes } from '@alfresco/aca-shared/store'; import { AppStore, SearchActionTypes } from '@alfresco/aca-shared/store';
@@ -36,7 +35,6 @@ describe('SearchExecutionService', () => {
let store: jasmine.SpyObj<Store<AppStore>>; let store: jasmine.SpyObj<Store<AppStore>>;
let filterService: jasmine.SpyObj<SearchFilterService>; let filterService: jasmine.SpyObj<SearchFilterService>;
let navigationService: jasmine.SpyObj<SearchNavigationService>; let navigationService: jasmine.SpyObj<SearchNavigationService>;
let queryBuilder: jasmine.SpyObj<SearchQueryBuilderService>;
let queryLibrariesBuilder: jasmine.SpyObj<SearchLibrariesQueryBuilderService>; let queryLibrariesBuilder: jasmine.SpyObj<SearchLibrariesQueryBuilderService>;
beforeEach(() => { beforeEach(() => {
@@ -48,7 +46,6 @@ describe('SearchExecutionService', () => {
onSearchResults: false, onSearchResults: false,
onLibrariesSearchResults: false onLibrariesSearchResults: false
}); });
queryBuilder = jasmine.createSpyObj('SearchQueryBuilderService', ['update']);
queryLibrariesBuilder = jasmine.createSpyObj('SearchLibrariesQueryBuilderService', ['update']); queryLibrariesBuilder = jasmine.createSpyObj('SearchLibrariesQueryBuilderService', ['update']);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -57,7 +54,6 @@ describe('SearchExecutionService', () => {
{ provide: Store, useValue: store }, { provide: Store, useValue: store },
{ provide: SearchFilterService, useValue: filterService }, { provide: SearchFilterService, useValue: filterService },
{ provide: SearchNavigationService, useValue: navigationService }, { provide: SearchNavigationService, useValue: navigationService },
{ provide: SearchQueryBuilderService, useValue: queryBuilder },
{ provide: SearchLibrariesQueryBuilderService, useValue: queryLibrariesBuilder } { provide: SearchLibrariesQueryBuilderService, useValue: queryLibrariesBuilder }
] ]
}); });
@@ -87,6 +83,24 @@ describe('SearchExecutionService', () => {
service.execute('test'); service.execute('test');
expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ type: SearchActionTypes.SearchByTerm, payload: 'test' })); expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ type: SearchActionTypes.SearchByTerm, payload: 'test' }));
}); });
it('should NOT dispatch when already on search results with the same term', () => {
Object.defineProperty(navigationService, 'onSearchResults', { get: () => true });
navigationService.isSameSearchTerm.and.returnValue(true);
service.execute('test');
expect(store.dispatch).not.toHaveBeenCalled();
});
it('should dispatch when already on search results with a different term', () => {
Object.defineProperty(navigationService, 'onSearchResults', { get: () => true });
navigationService.isSameSearchTerm.and.returnValue(false);
service.execute('test');
expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ type: SearchActionTypes.SearchByTerm, payload: 'test' }));
});
}); });
describe('libraries search', () => { describe('libraries search', () => {
@@ -25,7 +25,6 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppStore, SearchByTermAction } from '@alfresco/aca-shared/store'; import { AppStore, SearchByTermAction } from '@alfresco/aca-shared/store';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchFilterService } from './search-filter.service'; import { SearchFilterService } from './search-filter.service';
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';
@@ -33,7 +32,6 @@ import { SearchLibrariesQueryBuilderService } from './search-libraries-results/s
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SearchExecutionService { export class SearchExecutionService {
private readonly store = inject<Store<AppStore>>(Store); private readonly store = inject<Store<AppStore>>(Store);
private readonly queryBuilder = inject(SearchQueryBuilderService);
private readonly queryLibrariesBuilder = inject(SearchLibrariesQueryBuilderService); private readonly queryLibrariesBuilder = inject(SearchLibrariesQueryBuilderService);
private readonly filterService = inject(SearchFilterService); private readonly filterService = inject(SearchFilterService);
private readonly searchNavigationService = inject(SearchNavigationService); private readonly searchNavigationService = inject(SearchNavigationService);
@@ -61,9 +59,7 @@ export class SearchExecutionService {
} }
private executeContentSearch(searchedWord: string) { private executeContentSearch(searchedWord: string) {
if (this.searchNavigationService.onSearchResults && this.searchNavigationService.isSameSearchTerm(searchedWord)) { if (!(this.searchNavigationService.onSearchResults && this.searchNavigationService.isSameSearchTerm(searchedWord))) {
this.queryBuilder.update();
} else {
this.store.dispatch(new SearchByTermAction(searchedWord, this.filterService.searchOptions)); this.store.dispatch(new SearchByTermAction(searchedWord, this.filterService.searchOptions));
} }
} }
@@ -81,8 +81,10 @@ describe('SearchFilterService', () => {
expect(service.validateSearchTerm(' ')).toBe('SEARCH.INPUT.WHITESPACE'); expect(service.validateSearchTerm(' ')).toBe('SEARCH.INPUT.WHITESPACE');
}); });
it('should return error for term starting with operator', () => { it('should not return an operator error for terms containing special characters', () => {
expect(service.validateSearchTerm('+test')).toBe('SEARCH.INPUT.OPERATORS'); expect(service.validateSearchTerm('+test')).toBeNull();
expect(service.validateSearchTerm('AND test')).toBeNull();
expect(service.validateSearchTerm('test*')).toBeNull();
}); });
it('should return error for single char in libraries mode', () => { it('should return error for single char in libraries mode', () => {
@@ -25,7 +25,6 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services'; import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchOptionIds, SearchOptionModel } from '@alfresco/aca-shared/store'; import { SearchOptionIds, SearchOptionModel } from '@alfresco/aca-shared/store';
import { isOperator } from '../../utils/aca-search-utils';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SearchFilterService { export class SearchFilterService {
@@ -96,17 +95,10 @@ export class SearchFilterService {
return 'SEARCH.INPUT.WHITESPACE'; return 'SEARCH.INPUT.WHITESPACE';
} }
const words = term.trim().split(/\s+/);
if (isOperator(words[0]) || isOperator(words[words.length - 1])) {
return 'SEARCH.INPUT.OPERATORS';
}
if (/^[+\-|!(){}[\]^"~*?:\\/]/.test(term) || /[+\-|!(){}[\]^"~*?:\\/]$/.test(term)) {
return 'SEARCH.INPUT.OPERATORS';
}
if (this.searchInMode === SearchOptionIds.Libraries && term.length < 2) { if (this.searchInMode === SearchOptionIds.Libraries && term.length < 2) {
return 'SEARCH.INPUT.MIN_LENGTH'; return 'SEARCH.INPUT.MIN_LENGTH';
} }
return null; return null;
} }
@@ -17,12 +17,21 @@
[attr.aria-label]="'SEARCH.INPUT.ARIA-LABEL' | translate" [attr.aria-label]="'SEARCH.INPUT.ARIA-LABEL' | translate"
type="text" type="text"
[(ngModel)]="searchedWord" [(ngModel)]="searchedWord"
(keydown.enter)="onSearchSubmit(searchedWord)" (keydown.enter)="onSearchSubmit($event)"
[placeholder]="'SEARCH.INPUT.PLACEHOLDER' | translate" [placeholder]="'SEARCH.INPUT.PLACEHOLDER' | translate"
autocomplete="off" autocomplete="off"
/> />
<div matSuffix class="aca-search-input--suffix"> <div matSuffix class="aca-search-input--suffix">
<mat-button-toggle-group class="aca-search-input--suffix-button-toggle-group" [attr.aria-label]="'SEARCH.INPUT.SEARCH_MODE_TOGGLE_DESCRIPTION' | translate" [attr.title]="'SEARCH.INPUT.SEARCH_MODE_TOGGLE_DESCRIPTION' | translate" [(ngModel)]="queryBuilder.searchMode">
<mat-button-toggle value="regular" [attr.aria-label]="'SEARCH.INPUT.SEARCH_MODE_TOGGLE_DESCRIPTION_REGULAR' | translate">
<mat-icon>title</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="formula" [attr.aria-label]="'SEARCH.INPUT.SEARCH_MODE_TOGGLE_DESCRIPTION_FORMULA' | translate">
<mat-icon>extension</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
<aca-search-in-menu (filtersApplied)="onFiltersApplied()" /> <aca-search-in-menu (filtersApplied)="onFiltersApplied()" />
<button <button
@@ -1,4 +1,5 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
@use '../../../ui/mat-selectors' as ms;
$search-height: 48px; $search-height: 48px;
$search-border-radius: 4px; $search-border-radius: 4px;
@@ -56,6 +57,20 @@ $search-border-radius: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
&-button-toggle-group {
@include mat.button-toggle-overrides(
(
height: 30px
)
);
#{ms.$mat-icon} {
width: 18px;
height: 18px;
font-size: 18px;
}
}
} }
@media screen and (width <= 959px) { @media screen and (width <= 959px) {
@@ -26,35 +26,47 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin
import { AppTestingModule } from '../../../testing/app-testing.module'; import { AppTestingModule } from '../../../testing/app-testing.module';
import { SearchInputComponent } from './search-input.component'; import { SearchInputComponent } from './search-input.component';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { ActivatedRoute, Event, NavigationStart, Params, Router } from '@angular/router'; import { ActivatedRoute, Event, Params, Router } from '@angular/router';
import { SearchConfiguration, SearchQueryBuilderService } from '@alfresco/adf-content-services'; import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchNavigationService } from '../search-navigation.service'; import { SearchNavigationService } from '../search-navigation.service';
import { SearchFilterService } from '../search-filter.service'; import { SearchFilterService } from '../search-filter.service';
import { SearchExecutionService } from '../search-execution.service'; import { SearchExecutionService } from '../search-execution.service';
import { AppHookService } from '@alfresco/aca-shared'; import { AppHookService } from '@alfresco/aca-shared';
import { UnitTestingUtils } from '@alfresco/adf-core'; import { UnitTestingUtils } from '@alfresco/adf-core';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { HarnessLoader } from '@angular/cdk/testing';
import { MatButtonToggleGroupHarness } from '@angular/material/button-toggle/testing';
describe('SearchInputComponent', () => { describe('SearchInputComponent', () => {
let fixture: ComponentFixture<SearchInputComponent>; let fixture: ComponentFixture<SearchInputComponent>;
let component: SearchInputComponent; let component: SearchInputComponent;
let router: Router; let router: Router;
let queryBuilder: Partial<SearchQueryBuilderService>;
let searchExecutionService: jasmine.SpyObj<SearchExecutionService>; let searchExecutionService: jasmine.SpyObj<SearchExecutionService>;
let searchFilterService: jasmine.SpyObj<SearchFilterService>; let searchFilterService: jasmine.SpyObj<SearchFilterService>;
let searchNavigationService: jasmine.SpyObj<SearchNavigationService>; let searchNavigationService: jasmine.SpyObj<SearchNavigationService>;
let testingUtils: UnitTestingUtils; let testingUtils: UnitTestingUtils;
let loader: HarnessLoader;
const routerEventsSubject = new Subject<Event>(); const routerEventsSubject = new Subject<Event>();
const configUpdatedSubject = new Subject<SearchConfiguration>();
const queryParamsSubject = new Subject<Params>(); const queryParamsSubject = new Subject<Params>();
const library400ErrorSubject = new Subject<void>(); const library400ErrorSubject = new Subject<void>();
const submitSearch = (value: string) => {
const input = testingUtils.getInputByCSS('.app-search-input');
input.value = value;
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
testingUtils.keyBoardEventByCSS('.app-search-input', 'keydown', 'Enter', 'Enter');
fixture.detectChanges();
};
beforeEach(async () => { beforeEach(async () => {
const queryBuilderSpy = { queryBuilder = {
configUpdated: configUpdatedSubject, searchMode: 'regular',
removeFilterQuery: jasmine.createSpy('removeFilterQuery'), removeFilterQuery: jasmine.createSpy('removeFilterQuery'),
addFilterQuery: jasmine.createSpy('addFilterQuery'), addFilterQuery: jasmine.createSpy('addFilterQuery')
update: jasmine.createSpy('update')
} as Partial<SearchQueryBuilderService>; } as Partial<SearchQueryBuilderService>;
searchExecutionService = jasmine.createSpyObj<SearchExecutionService>('SearchExecutionService', ['execute']); searchExecutionService = jasmine.createSpyObj<SearchExecutionService>('SearchExecutionService', ['execute']);
@@ -83,7 +95,7 @@ describe('SearchInputComponent', () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AppTestingModule, SearchInputComponent, NoopAnimationsModule], imports: [AppTestingModule, SearchInputComponent, NoopAnimationsModule],
providers: [ providers: [
{ provide: SearchQueryBuilderService, useValue: queryBuilderSpy }, { provide: SearchQueryBuilderService, useValue: queryBuilder },
{ provide: SearchExecutionService, useValue: searchExecutionService }, { provide: SearchExecutionService, useValue: searchExecutionService },
{ provide: SearchFilterService, useValue: searchFilterService }, { provide: SearchFilterService, useValue: searchFilterService },
{ provide: SearchNavigationService, useValue: searchNavigationService }, { provide: SearchNavigationService, useValue: searchNavigationService },
@@ -100,6 +112,7 @@ describe('SearchInputComponent', () => {
fixture = TestBed.createComponent(SearchInputComponent); fixture = TestBed.createComponent(SearchInputComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement); testingUtils = new UnitTestingUtils(fixture.debugElement);
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -134,38 +147,54 @@ describe('SearchInputComponent', () => {
describe('onSearchSubmit', () => { describe('onSearchSubmit', () => {
it('should execute search on submit with valid term', () => { it('should execute search on submit with valid term', () => {
component.onSearchSubmit('happy faces only'); submitSearch('happy faces only');
expect(searchExecutionService.execute).toHaveBeenCalledWith('happy faces only'); expect(searchExecutionService.execute).toHaveBeenCalledWith('happy faces only');
}); });
it('should not execute search when search term is empty', () => { it('should not execute search when search term is empty', () => {
searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY'); searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY');
component.onSearchSubmit(''); submitSearch('');
expect(searchExecutionService.execute).not.toHaveBeenCalled(); expect(searchExecutionService.execute).not.toHaveBeenCalled();
}); });
it('should not execute search when search term is whitespace', () => { it('should not execute search when search term is whitespace', () => {
searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY'); searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY');
component.onSearchSubmit(' '); submitSearch(' ');
expect(searchExecutionService.execute).not.toHaveBeenCalled(); expect(searchExecutionService.execute).not.toHaveBeenCalled();
}); });
it('should set error when validation fails', () => { it('should set error when validation fails', () => {
searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY'); searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY');
component.onSearchSubmit(''); submitSearch('');
expect(component.error).toBe('SEARCH.ERRORS.EMPTY_QUERY'); expect(component.error).toBe('SEARCH.ERRORS.EMPTY_QUERY');
}); });
it('should clear error on valid submission', () => { it('should clear error on valid submission', () => {
component.error = 'some error'; component.error = 'some error';
component.onSearchSubmit('valid term'); submitSearch('valid term');
expect(component.error).toBe(''); expect(component.error).toBe('');
}); });
it('should trim whitespace from search term', () => { it('should track the trimmed term as the last searched word', () => {
component.onSearchSubmit(' hello '); submitSearch(' hello ');
expect(component.searchedWord).toBe('hello'); expect(component.lastSearchedWord).toBe('hello');
expect(searchExecutionService.execute).toHaveBeenCalledWith('hello'); expect(searchExecutionService.execute).toHaveBeenCalledTimes(1);
});
it('should not re-execute search when submitted term is unchanged', () => {
submitSearch('hello');
expect(searchExecutionService.execute).toHaveBeenCalledTimes(1);
submitSearch('hello');
expect(searchExecutionService.execute).toHaveBeenCalledTimes(1);
});
it('should not re-execute search when only surrounding whitespace changes', () => {
submitSearch('hello');
expect(searchExecutionService.execute).toHaveBeenCalledTimes(1);
submitSearch(' hello ');
expect(searchExecutionService.execute).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -254,29 +283,30 @@ describe('SearchInputComponent', () => {
}); });
}); });
describe('queryBuilder configUpdated handling', () => { describe('search mode toggle', () => {
it('should execute search when searchedWord is set and navigation has query params', () => { const getToggleGroup = (): Promise<MatButtonToggleGroupHarness> =>
component.searchedWord = 'term'; loader.getHarness(MatButtonToggleGroupHarness.with({ selector: '.aca-search-input--suffix-button-toggle-group' }));
routerEventsSubject.next(new NavigationStart(1, '/path?q=term'));
configUpdatedSubject.next({ id: 'config1' } as SearchConfiguration);
expect(searchExecutionService.execute).toHaveBeenCalledWith('term'); it('should render the regular and formula search mode toggles', async () => {
const group = await getToggleGroup();
const toggles = await group.getToggles();
expect(toggles.length).toBe(2);
expect(await toggles[0].getText()).toBe('title');
expect(await toggles[1].getText()).toBe('extension');
}); });
it('should NOT execute search when navigation has no query params', () => { it('should reflect the current queryBuilder search mode', async () => {
searchExecutionService.execute.calls.reset(); const group = await getToggleGroup();
component.searchedWord = 'term'; const toggles = await group.getToggles();
routerEventsSubject.next(new NavigationStart(1, '/path')); expect(await toggles[0].isChecked()).toBeTrue();
configUpdatedSubject.next({} as SearchConfiguration); expect(await toggles[1].isChecked()).toBeFalse();
expect(searchExecutionService.execute).not.toHaveBeenCalled();
}); });
it('should NOT execute search when searchedWord is not set', () => { it('should update queryBuilder search mode to formula when formula toggle is selected', async () => {
searchExecutionService.execute.calls.reset(); const group = await getToggleGroup();
component.searchedWord = null; const toggles = await group.getToggles();
routerEventsSubject.next(new NavigationStart(1, '/path?q=term')); await toggles[1].check();
configUpdatedSubject.next({} as SearchConfiguration); expect(queryBuilder.searchMode).toBe('formula');
expect(searchExecutionService.execute).not.toHaveBeenCalled();
}); });
}); });
@@ -23,9 +23,8 @@
*/ */
import { AppHookService } from '@alfresco/aca-shared'; import { AppHookService } from '@alfresco/aca-shared';
import { AppConfigService } from '@alfresco/adf-core';
import { AfterViewInit, Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { AfterViewInit, Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ActivatedRoute, NavigationSkipped, NavigationStart, Params, Router } from '@angular/router'; import { ActivatedRoute, NavigationSkipped, Params, Router } from '@angular/router';
import { SearchNavigationService } from '../search-navigation.service'; import { SearchNavigationService } from '../search-navigation.service';
import { SearchFilterService } from '../search-filter.service'; import { SearchFilterService } from '../search-filter.service';
import { SearchExecutionService } from '../search-execution.service'; import { SearchExecutionService } from '../search-execution.service';
@@ -39,12 +38,23 @@ import { FormsModule } from '@angular/forms';
import { SearchInMenuComponent } from '../search-in-menu/search-in-menu.component'; import { SearchInMenuComponent } from '../search-in-menu/search-in-menu.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { merge } from 'rxjs/internal/observable/merge'; import { merge } from 'rxjs/internal/observable/merge';
import { filter, map, startWith, withLatestFrom } from 'rxjs'; import { filter, map, withLatestFrom } from 'rxjs';
import { extractUserQueryFromEncodedQuery } from '../../../utils/aca-search-utils';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services'; import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
@Component({ @Component({
imports: [CommonModule, TranslatePipe, MatButtonModule, MatIconModule, MatFormFieldModule, MatInputModule, FormsModule, SearchInMenuComponent], imports: [
CommonModule,
TranslatePipe,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
SearchInMenuComponent,
MatButtonToggleModule
],
selector: 'aca-search-input', selector: 'aca-search-input',
templateUrl: './search-input.component.html', templateUrl: './search-input.component.html',
styleUrls: ['./search-input.component.scss'], styleUrls: ['./search-input.component.scss'],
@@ -52,18 +62,17 @@ import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-u
host: { class: 'aca-search-input' } host: { class: 'aca-search-input' }
}) })
export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy { export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly queryBuilder = inject(SearchQueryBuilderService);
private readonly config = inject(AppConfigService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly appHookService = inject(AppHookService); private readonly appHookService = inject(AppHookService);
private readonly filterService = inject(SearchFilterService); private readonly filterService = inject(SearchFilterService);
private readonly searchExecutionService = inject(SearchExecutionService); private readonly searchExecutionService = inject(SearchExecutionService);
readonly searchNavigationService = inject(SearchNavigationService); readonly searchNavigationService = inject(SearchNavigationService);
readonly queryBuilder = inject(SearchQueryBuilderService);
has400LibraryError = false; has400LibraryError = false;
searchOnChange: boolean;
searchedWord: string = null; searchedWord: string = null;
lastSearchedWord: string = null;
error = ''; error = '';
@ViewChild('searchInputField') @ViewChild('searchInputField')
@@ -71,10 +80,6 @@ export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
constructor() {
this.searchOnChange = this.config.get<boolean>('search.aca:triggeredOnChange', true);
}
ngOnInit(): void { ngOnInit(): void {
this.initSearchState(); this.initSearchState();
this.subscribeToRouteParams(); this.subscribeToRouteParams();
@@ -98,8 +103,8 @@ export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
this.searchNavigationService.navigateBack(); this.searchNavigationService.navigateBack();
} }
onSearchSubmit(searchTerm: string) { onSearchSubmit(event: Event) {
const trimmedTerm = searchTerm?.trim(); const trimmedTerm = (event.target as HTMLInputElement).value?.trim();
const validationError = this.filterService.validateSearchTerm(trimmedTerm); const validationError = this.filterService.validateSearchTerm(trimmedTerm);
if (validationError) { if (validationError) {
@@ -107,9 +112,12 @@ export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
return; return;
} }
this.searchedWord = trimmedTerm;
this.error = ''; this.error = '';
this.executeSearch(); if (this.lastSearchedWord !== trimmedTerm) {
this.lastSearchedWord = trimmedTerm;
this.searchedWord = trimmedTerm;
this.executeSearch();
}
} }
onFiltersApplied() { onFiltersApplied() {
@@ -143,24 +151,7 @@ export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
.subscribe((params: Params) => { .subscribe((params: Params) => {
const encodedQuery = params['q']; const encodedQuery = params['q'];
if (encodedQuery) { if (encodedQuery) {
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery); this.searchedWord = extractUserQueryFromEncodedQuery(encodedQuery);
}
});
this.queryBuilder.configUpdated
.pipe(
takeUntilDestroyed(this.destroyRef),
withLatestFrom(
this.router.events.pipe(
filter((event): event is NavigationStart => event instanceof NavigationStart),
startWith(null)
)
)
)
.subscribe(([, navigationStartEvent]) => {
const hasQueryParams = navigationStartEvent?.url.includes('?');
if (this.searchedWord && hasQueryParams) {
this.searchExecutionService.execute(this.searchedWord);
} }
}); });
} }
@@ -75,8 +75,8 @@ describe('SearchLibrariesResultsComponent', () => {
expect(component.onSearchResultLoaded).toHaveBeenCalledWith(emptyPage); expect(component.onSearchResultLoaded).toHaveBeenCalledWith(emptyPage);
}); });
it('should extract searched word from query params', (done) => { it('should extract the user query from query params', (done) => {
route.queryParams = of({ q: encodeQuery({ userQuery: 'cm:name:"test*"' }) }); route.queryParams = of({ q: encodeQuery({ userQuery: 'test' }) });
route.queryParams.subscribe(() => { route.queryParams.subscribe(() => {
fixture.detectChanges(); fixture.detectChanges();
expect(component.searchedWord).toBe('test'); expect(component.searchedWord).toBe('test');
@@ -44,7 +44,7 @@ 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 { extractUserQueryFromEncodedQuery } from '../../../utils/aca-search-utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ @Component({
@@ -129,7 +129,7 @@ export class SearchLibrariesResultsComponent extends PageComponent implements On
if (this.route) { if (this.route) {
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => {
const encodedQuery = params[this.queryParamName] || null; const encodedQuery = params[this.queryParamName] || null;
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery); this.searchedWord = extractUserQueryFromEncodedQuery(encodedQuery);
if (this.searchedWord?.length > 1) { if (this.searchedWord?.length > 1) {
this.librariesQueryBuilder.paging.skipCount = 0; this.librariesQueryBuilder.paging.skipCount = 0;
this.librariesQueryBuilder.userQuery = this.searchedWord; this.librariesQueryBuilder.userQuery = this.searchedWord;
@@ -26,11 +26,14 @@ import { TestBed } from '@angular/core/testing';
import { SearchNavigationService } from './search-navigation.service'; import { SearchNavigationService } from './search-navigation.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AppTestingModule } from '../../testing/app-testing.module'; import { AppTestingModule } from '../../testing/app-testing.module';
import { Buffer } from 'buffer';
describe('SearchNavigationService', () => { describe('SearchNavigationService', () => {
let service: SearchNavigationService; let service: SearchNavigationService;
let router: Router; let router: Router;
const encodeQuery = (query: any): string => Buffer.from(JSON.stringify(query)).toString('base64');
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [AppTestingModule] imports: [AppTestingModule]
@@ -69,4 +72,31 @@ describe('SearchNavigationService', () => {
expect(routerNavigate).toHaveBeenCalledWith(['/personal-files']); expect(routerNavigate).toHaveBeenCalledWith(['/personal-files']);
}); });
describe('getUrlSearchTerm', () => {
it('should return empty string when not on a search results route', () => {
spyOnProperty(router, 'url', 'get').and.returnValue('/personal-files');
expect(service.getUrlSearchTerm()).toBe('');
});
it('should return the raw user query extracted from the encoded q parameter', () => {
const encodedQuery = encodeQuery({ userQuery: 'my term' });
spyOnProperty(router, 'url', 'get').and.returnValue(`/search;q=${encodedQuery}`);
expect(service.getUrlSearchTerm()).toBe('my term');
});
});
describe('isSameSearchTerm', () => {
it('should return true when the provided term matches the url search term', () => {
const encodedQuery = encodeQuery({ userQuery: 'my term' });
spyOnProperty(router, 'url', 'get').and.returnValue(`/search;q=${encodedQuery}`);
expect(service.isSameSearchTerm('my term')).toBeTrue();
});
it('should return false when the provided term differs from the url search term', () => {
const encodedQuery = encodeQuery({ userQuery: 'my term' });
spyOnProperty(router, 'url', 'get').and.returnValue(`/search;q=${encodedQuery}`);
expect(service.isSameSearchTerm('other term')).toBeFalse();
});
});
}); });
@@ -24,7 +24,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router'; import { PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { extractSearchedWordFromEncodedQuery } from '../../utils/aca-search-utils'; import { extractUserQueryFromEncodedQuery } from '../../utils/aca-search-utils';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -73,7 +73,7 @@ export class SearchNavigationService {
if (urlSegmentGroup) { if (urlSegmentGroup) {
const urlSegments: UrlSegment[] = urlSegmentGroup.segments; const urlSegments: UrlSegment[] = urlSegmentGroup.segments;
return extractSearchedWordFromEncodedQuery(urlSegments[0].parameters['q']); return extractUserQueryFromEncodedQuery(urlSegments[0].parameters['q']);
} }
return ''; return '';
@@ -61,7 +61,6 @@ describe('SearchComponent', () => {
let showErrorSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>; let showErrorSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
let showInfoSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>; let showInfoSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
let loader: HarnessLoader; let loader: HarnessLoader;
let updatedSubjectMock: Subject<SearchRequest>;
const editSavedSearchesSpy = jasmine.createSpy('editSavedSearch'); const editSavedSearchesSpy = jasmine.createSpy('editSavedSearch');
const getSavedSearchButton = (): HTMLButtonElement => fixture.nativeElement.querySelector('.aca-content__save-search-action'); const getSavedSearchButton = (): HTMLButtonElement => fixture.nativeElement.querySelector('.aca-content__save-search-action');
@@ -76,7 +75,6 @@ describe('SearchComponent', () => {
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' }); params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' });
queryParams = new Subject(); queryParams = new Subject();
routerEvents = new Subject(); routerEvents = new Subject();
updatedSubjectMock = new Subject();
const routerMock = jasmine.createSpyObj<Router>('Router', ['navigate'], { const routerMock = jasmine.createSpyObj<Router>('Router', ['navigate'], {
url: '/mock-search-url', url: '/mock-search-url',
@@ -128,8 +126,6 @@ describe('SearchComponent', () => {
router = TestBed.inject(Router); router = TestBed.inject(Router);
route = TestBed.inject(ActivatedRoute); route = TestBed.inject(ActivatedRoute);
queryBuilder.updated = updatedSubjectMock;
const notificationService = TestBed.inject(NotificationService); const notificationService = TestBed.inject(NotificationService);
showErrorSpy = spyOn(notificationService, 'showError'); showErrorSpy = spyOn(notificationService, 'showError');
showInfoSpy = spyOn(notificationService, 'showInfo'); showInfoSpy = spyOn(notificationService, 'showInfo');
@@ -141,8 +137,6 @@ describe('SearchComponent', () => {
fixture = TestBed.createComponent(SearchResultsComponent); fixture = TestBed.createComponent(SearchResultsComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
spyOn(queryBuilder, 'update').and.stub();
fixture.detectChanges(); fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture); loader = TestbedHarnessEnvironment.loader(fixture);
}); });
@@ -232,6 +226,7 @@ describe('SearchComponent', () => {
}); });
it('should re-run search on pagination change', () => { it('should re-run search on pagination change', () => {
const executeSpy = spyOn(queryBuilder, 'execute');
const page = new Pagination({ const page = new Pagination({
maxItems: 10, maxItems: 10,
skipCount: 0 skipCount: 0
@@ -243,25 +238,60 @@ describe('SearchComponent', () => {
maxItems: 10, maxItems: 10,
skipCount: 0 skipCount: 0
}); });
expect(queryBuilder.update).toHaveBeenCalled(); expect(executeSpy).toHaveBeenCalledWith(false);
});
it('should re-run search on sorting change', () => {
const executeSpy = spyOn(queryBuilder, 'execute');
component.onSearchSortingUpdate({ key: 'name', ascending: false } as any);
expect(queryBuilder.sorting).toEqual([jasmine.objectContaining({ key: 'name', ascending: false })]);
expect(executeSpy).toHaveBeenCalledWith(false);
}); });
it('should update the user query, populate filters state and execute query whenever param changed', (done) => { it('should update the user query, populate filters state and execute query whenever param changed', (done) => {
spyOn(queryBuilder.populateFilters, 'next'); spyOn(queryBuilder.populateFilters, 'next');
spyOn(queryBuilder, 'execute'); spyOn(queryBuilder, 'execute');
const query = { userQuery: 'cm:tag:"orange*"', filterProp: { prop: 'test' } }; const query = { userQuery: 'orange', filterProp: { prop: 'test' } };
route.queryParams = of({ q: encodeQuery(query) }); route.queryParams = of({ q: encodeQuery(query) });
component.ngOnInit(); component.ngOnInit();
route.queryParams.subscribe(() => { route.queryParams.subscribe(() => {
expect(component.searchedWord).toBe(`orange`); expect(component.searchedWord).toBe(`orange`);
expect(queryBuilder.userQuery).toBe(`(cm:tag:"orange*")`); expect(queryBuilder.userQuery).toBe(`orange`);
expect(queryBuilder.populateFilters.next).toHaveBeenCalledWith({ userQuery: 'cm:tag:"orange*"', filterProp: { prop: 'test' } }); expect(queryBuilder.populateFilters.next).toHaveBeenCalledWith({ userQuery: 'orange', filterProp: { prop: 'test' } });
queryBuilder.filterLoaded.next(); queryBuilder.filterLoaded.next();
fixture.detectChanges(); fixture.detectChanges();
done(); done();
}); });
}); });
it('should apply search mode and selected configuration from the encoded query', (done) => {
spyOn(queryBuilder, 'updateSelectedConfiguration');
spyOn(queryBuilder, 'execute');
const query = { userQuery: 'orange', searchMode: 'formula', selectedConfigurationId: 'config-1' };
route.queryParams = of({ q: encodeQuery(query) });
component.ngOnInit();
route.queryParams.subscribe(() => {
expect(queryBuilder.searchMode).toBe('formula');
expect(queryBuilder.updateSelectedConfiguration).toHaveBeenCalledWith('config-1', false, false);
queryBuilder.filterLoaded.next();
done();
});
});
it('should default search mode to regular when not present in the encoded query', (done) => {
spyOn(queryBuilder, 'execute');
const query = { userQuery: 'orange' };
route.queryParams = of({ q: encodeQuery(query) });
component.ngOnInit();
route.queryParams.subscribe(() => {
expect(queryBuilder.searchMode).toBe('regular');
queryBuilder.filterLoaded.next();
done();
});
});
it('should get initial saved search when url matches', () => { it('should get initial saved search when url matches', () => {
route.queryParams = of({ q: encodeQuery({ name: 'test' }) }); route.queryParams = of({ q: encodeQuery({ name: 'test' }) });
component.ngOnInit(); component.ngOnInit();
@@ -370,30 +400,18 @@ describe('SearchComponent', () => {
expect(executeSpy).toHaveBeenCalledTimes(1); expect(executeSpy).toHaveBeenCalledTimes(1);
})); }));
it('should format userQuery when url parameters changed and userQuery is not contained by url', () => { it('should set the raw userQuery from the encoded query when url parameters change', () => {
routerEvents.next(new NavigationStart(1, ''));
queryParams.next({ q: encodeQuery('') });
expect(queryBuilder.userQuery).toBe('((cm:name:"*"))');
});
it('should not format userQuery when url parameters changed when userQuery is already contained by url', () => {
routerEvents.next(new NavigationStart(1, '')); routerEvents.next(new NavigationStart(1, ''));
queryParams.next({ q: encodeQuery({ userQuery: 'test' }) }); queryParams.next({ q: encodeQuery({ userQuery: 'test' }) });
expect(queryBuilder.userQuery).toBe('(test)'); expect(queryBuilder.userQuery).toBe('test');
}); });
it('should set loading to true in updated stream for non-nullish query', fakeAsync(() => { it('should set loading to true when filterQueryUpdate emits', fakeAsync(() => {
spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
expect(component.isLoading).toBeFalse(); expect(component.isLoading).toBeFalse();
updatedSubjectMock.next(null); queryBuilder.filterQueryUpdate.next();
tick();
expect(component.isLoading).toBeFalse();
updatedSubjectMock.next({} as SearchRequest);
tick(); tick();
@@ -30,7 +30,6 @@ import {
DocumentListComponent, DocumentListComponent,
ResetSearchDirective, ResetSearchDirective,
SavedSearch, SavedSearch,
SearchConfiguration,
SearchFilterChipsComponent, SearchFilterChipsComponent,
SearchFormComponent, SearchFormComponent,
SearchSortingDefinition, SearchSortingDefinition,
@@ -77,12 +76,7 @@ import { MatIconModule } from '@angular/material/icon';
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 { import { extractFiltersFromEncodedQuery, extractUserQueryFromEncodedQuery } from '../../../utils/aca-search-utils';
extractFiltersFromEncodedQuery,
extractSearchedWordFromEncodedQuery,
extractUserQueryFromEncodedQuery,
formatSearchTerm
} from '../../../utils/aca-search-utils';
import { SaveSearchDirective } from '../search-save/directive/save-search.directive'; import { SaveSearchDirective } from '../search-save/directive/save-search.directive';
import { combineLatest, Observable, of } from 'rxjs'; import { combineLatest, Observable, of } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -137,7 +131,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly translationService = inject(TranslationService); private readonly translationService = inject(TranslationService);
private readonly savedSearchesService = inject(SavedSearchesContextService); private readonly savedSearchesService = inject(SavedSearchesContextService);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
infoDrawerPreview$ = this.store.select(infoDrawerPreview); infoDrawerPreview$ = this.store.select(infoDrawerPreview);
@@ -154,7 +147,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
initialSavedSearch: SavedSearch = undefined; initialSavedSearch: SavedSearch = undefined;
columns: DocumentListPresetRef[] = []; columns: DocumentListPresetRef[] = [];
encodedQuery: string; encodedQuery: string;
searchConfig: SearchConfiguration;
isSmallScreen = window.innerWidth < 320; isSmallScreen = window.innerWidth < 320;
private previousEncodedQuery: string; private previousEncodedQuery: string;
@@ -175,10 +167,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
maxItems: 25 maxItems: 25
}; };
this.queryBuilder.configUpdated.pipe(takeUntilDestroyed()).subscribe((searchConfig) => {
this.searchConfig = searchConfig;
});
this.areFiltersActive$ = combineLatest([this.queryBuilder.queryFragmentsUpdate, this.queryBuilder.userFacetBucketsUpdate]).pipe( this.areFiltersActive$ = combineLatest([this.queryBuilder.queryFragmentsUpdate, this.queryBuilder.userFacetBucketsUpdate]).pipe(
takeUntilDestroyed(), takeUntilDestroyed(),
map((filters) => { map((filters) => {
@@ -197,12 +185,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
this.sorting = this.getSorting(); this.sorting = this.getSorting();
this.subscriptions.push( this.subscriptions.push(
this.queryBuilder.updated.pipe(filter(Boolean)).subscribe(() => {
this.isLoading = true;
this.sorting = this.getSorting();
this.changeDetectorRef.detectChanges();
}),
this.queryBuilder.executed.subscribe((data) => { this.queryBuilder.executed.subscribe((data) => {
this.queryBuilder.paging.skipCount = 0; this.queryBuilder.paging.skipCount = 0;
@@ -237,13 +219,16 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
tap(([params]) => { tap(([params]) => {
this.queryBuilder.userQuery = '';
this.encodedQuery = params[this.queryParamName]; this.encodedQuery = params[this.queryParamName];
this.isLoading = !!this.encodedQuery; this.isLoading = !!this.encodedQuery;
this.searchedWord = extractSearchedWordFromEncodedQuery(this.encodedQuery); this.searchedWord = extractUserQueryFromEncodedQuery(this.encodedQuery);
const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(this.encodedQuery); const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(this.encodedQuery);
this.queryBuilder.searchMode = filtersFromEncodedQuery?.['searchMode'] ?? 'regular';
if (filtersFromEncodedQuery?.['selectedConfigurationId']) {
this.queryBuilder.updateSelectedConfiguration(filtersFromEncodedQuery['selectedConfigurationId'], false, false);
}
this.queryBuilder.populateFilters.next(filtersFromEncodedQuery || {}); this.queryBuilder.populateFilters.next(filtersFromEncodedQuery || {});
}), }),
switchMap(([, navigationStartEvent]) => { switchMap(([, navigationStartEvent]) => {
@@ -257,9 +242,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
.subscribe((navigationStartEvent) => { .subscribe((navigationStartEvent) => {
const shouldExecuteQuery = this.shouldExecuteQuery(navigationStartEvent, this.encodedQuery); const shouldExecuteQuery = this.shouldExecuteQuery(navigationStartEvent, this.encodedQuery);
this.queryBuilder.userQuery = extractUserQueryFromEncodedQuery(this.encodedQuery); this.queryBuilder.userQuery = extractUserQueryFromEncodedQuery(this.encodedQuery);
if (!this.searchedWord && !this.queryBuilder.userQuery && this.encodedQuery) {
this.queryBuilder.userQuery = formatSearchTerm('*', this.searchConfig['app:fields']);
}
if (shouldExecuteQuery) { if (shouldExecuteQuery) {
this.queryBuilder.execute(false); this.queryBuilder.execute(false);
@@ -311,7 +293,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
maxItems: pagination.maxItems, maxItems: pagination.maxItems,
skipCount: pagination.skipCount skipCount: pagination.skipCount
}; };
this.queryBuilder.update(); this.queryBuilder.execute(false);
} }
private getSorting(): string[] { private getSorting(): string[] {
@@ -350,7 +332,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
onSearchSortingUpdate(option: SearchSortingDefinition) { onSearchSortingUpdate(option: SearchSortingDefinition) {
this.queryBuilder.sorting = [{ ...option, ascending: option.ascending }]; this.queryBuilder.sorting = [{ ...option, ascending: option.ascending }];
this.queryBuilder.update(); this.queryBuilder.execute(false);
} }
editSavedSearch(searchToSave: SavedSearch) { editSavedSearch(searchToSave: SavedSearch) {
@@ -381,9 +363,15 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
const hasQueryChanged = query !== this.previousEncodedQuery; const hasQueryChanged = query !== this.previousEncodedQuery;
this.previousEncodedQuery = query; this.previousEncodedQuery = query;
if (!navigationStartEvent || navigationStartEvent.navigationTrigger === 'popstate' || navigationStartEvent.navigationTrigger === 'hashchange') { if (!navigationStartEvent && this.data === undefined) {
return true; return true;
} else if (navigationStartEvent.navigationTrigger === 'imperative') { } else if (query === this.queryBuilder.encodedQuery) {
return false;
} else if (
navigationStartEvent?.navigationTrigger === 'popstate' ||
navigationStartEvent?.navigationTrigger === 'hashchange' ||
navigationStartEvent?.navigationTrigger === 'imperative'
) {
return hasQueryChanged; return hasQueryChanged;
} else { } else {
return !!query; return !!query;
@@ -255,7 +255,8 @@ export class NodeActionsService {
isSelectionValid: this.canCopyMoveInsideIt.bind(this), isSelectionValid: this.canCopyMoveInsideIt.bind(this),
breadcrumbTransform: this.customizeBreadcrumb.bind(this), breadcrumbTransform: this.customizeBreadcrumb.bind(this),
select: new Subject<Node[]>(), select: new Subject<Node[]>(),
excludeSiteContent: ContentNodeDialogService.nonDocumentSiteContent excludeSiteContent: ContentNodeDialogService.nonDocumentSiteContent,
showFilesInResult: false
}; };
} }
@@ -77,7 +77,8 @@ export class NodeTemplateService {
showSearch: false, showSearch: false,
showDropdownSiteList: false, showDropdownSiteList: false,
isSelectionValid: this.isSelectionValid.bind(this), isSelectionValid: this.isSelectionValid.bind(this),
rowFilter: this.rowFilter.bind(this) rowFilter: this.rowFilter.bind(this),
showFilesInResult: false
}; };
const query = { const query = {
@@ -50,14 +50,14 @@ describe('SearchEffects', () => {
}); });
describe('searchByTerm$', () => { describe('searchByTerm$', () => {
it('should navigate to `search` when search options has library false', fakeAsync(() => { it('should navigate to `search` with the raw term when search options has library false', fakeAsync(() => {
spyOn(queryBuilder, 'navigateToSearch'); spyOn(queryBuilder, 'navigateToSearch');
store.dispatch(new SearchByTermAction('test', [])); store.dispatch(new SearchByTermAction('test', []));
tick(); tick();
expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('(cm:name:"test*")', '/search'); expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('test', '/search');
})); }));
it('should navigate to `search-libraries` when search options has library true', fakeAsync(() => { it('should navigate to `search-libraries` with the raw term when search options has library true', fakeAsync(() => {
spyOn(queryBuilder, 'navigateToSearch'); spyOn(queryBuilder, 'navigateToSearch');
store.dispatch( store.dispatch(
new SearchByTermAction('test', [ new SearchByTermAction('test', [
@@ -72,7 +72,7 @@ describe('SearchEffects', () => {
tick(); tick();
expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('(cm:name:"test*")', '/search-libraries'); expect(queryBuilder.navigateToSearch).toHaveBeenCalledWith('test', '/search-libraries');
})); }));
}); });
@@ -28,7 +28,6 @@ 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 { SearchNavigationService } from '../../components/search/search-navigation.service'; import { SearchNavigationService } from '../../components/search/search-navigation.service';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services'; import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { formatSearchTerm } from '../../utils/aca-search-utils';
@Injectable() @Injectable()
export class SearchEffects { export class SearchEffects {
@@ -52,10 +51,9 @@ export class SearchEffects {
this.actions$.pipe( this.actions$.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm), ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => { map((action) => {
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;
this.queryBuilder.navigateToSearch(query, librarySelected ? '/search-libraries' : '/search'); this.queryBuilder.navigateToSearch(action.payload, librarySelected ? '/search-libraries' : '/search');
}) })
), ),
{ dispatch: false } { dispatch: false }
@@ -24,7 +24,7 @@
import { import {
extractFiltersFromEncodedQuery, extractFiltersFromEncodedQuery,
extractSearchedWordFromEncodedQuery, extractParsedQueryFromEncodedQuery,
extractUserQueryFromEncodedQuery, extractUserQueryFromEncodedQuery,
formatSearchTerm, formatSearchTerm,
formatSearchTermByFields, formatSearchTermByFields,
@@ -62,20 +62,20 @@ describe('SearchUtils', () => {
}); });
describe('formatSearchTermByFields', () => { describe('formatSearchTermByFields', () => {
it('should append "*" to search term', () => { it('should not append wildcard by default', () => {
expect(formatSearchTermByFields('test', ['name'])).toBe('(name:"test*")'); expect(formatSearchTermByFields('test', ['name'])).toBe('(name:"test")');
}); });
it('should not prefix when search term equals "*"', () => { it('should append "*" to search term when wildcards are enabled', () => {
expect(formatSearchTermByFields('*', ['name'])).toBe('(name:"*")'); expect(formatSearchTermByFields('test', ['name'], true)).toBe('(name:"test*")');
});
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', () => { it('should format search term with set of fields and join with OR', () => {
expect(formatSearchTermByFields('test', ['name', 'size'])).toBe('(name:"test*" OR size:"test*")'); expect(formatSearchTermByFields('test', ['name', 'size'])).toBe('(name:"test" OR size:"test")');
});
it('should format search term with set of fields and append wildcards when enabled', () => {
expect(formatSearchTermByFields('test', ['name', 'size'], true)).toBe('(name:"test*" OR size:"test*")');
}); });
}); });
@@ -85,43 +85,43 @@ describe('SearchUtils', () => {
expect(formatSearchTerm(undefined)).toEqual(''); expect(formatSearchTerm(undefined)).toEqual('');
}); });
it('should not transfer custom queries', () => { it('should return the raw input untouched in formula mode', () => {
expect(formatSearchTerm('test:"term"')).toBe('test:"term"'); expect(formatSearchTerm('test:"term"', ['cm:name'], 'formula')).toBe('test:"term"');
expect(formatSearchTerm('"test"')).toBe('"test"'); expect(formatSearchTerm('cm:name:"foo" AND TEXT:bar', ['cm:name'], 'formula')).toBe('cm:name:"foo" AND TEXT:bar');
}); });
it('should properly join multiple word search term', () => { 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')).toBe('((cm:name:"test") AND (cm:name:"word") AND (cm:name:"term"))');
expect(formatSearchTerm('test word term', ['name', 'size'])).toBe( 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*")' '((name:"test" OR size:"test") AND (name:"word" OR size:"word") AND (name:"term" OR size:"term"))'
);
});
it('should append wildcards to every word when wildcards are enabled', () => {
expect(formatSearchTerm('test word term', ['cm:name'], 'regular', true)).toBe(
'((cm:name:"test*") AND (cm:name:"word*") AND (cm:name:"term*"))'
); );
}); });
it('should format user input as cm:name if configuration not provided', () => { it('should format user input as cm:name if configuration not provided', () => {
expect(formatSearchTerm('hello')).toBe(`(cm:name:"hello*")`); expect(formatSearchTerm('hello')).toBe(`((cm:name:"hello"))`);
}); });
it('should support conjunctions with AND operator', () => { it('should support conjunctions with AND operator', () => {
expect(formatSearchTerm('big AND yellow AND banana', ['cm:name', 'cm:title'])).toBe( 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*")` `((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', () => { it('should support conjunctions with OR operator', () => {
expect(formatSearchTerm('big OR yellow OR banana', ['cm:name', 'cm:title'])).toBe( 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*")` `((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")`
); );
}); });
it('should split words correctly when multiple whitespaces are present', () => { it('should split words correctly when multiple whitespaces are present', () => {
expect(formatSearchTerm(' big yellow ', ['cm:name', 'cm:title'])).toBe( expect(formatSearchTerm(' big yellow ', ['cm:name', 'cm:title'])).toBe(
`(cm:name:"big*" OR cm:title:"big*") AND (cm:name:"yellow*" OR cm:title:"yellow*")` `((cm:name:"big" OR cm:title:"big") AND (cm:name:"yellow" OR cm:title:"yellow"))`
); );
}); });
}); });
@@ -137,62 +137,26 @@ describe('SearchUtils', () => {
expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('cm:name:"test"'); expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('cm:name:"test"');
}); });
it('should properly trim set of parentheses from extracted user query', () => { it('should return the raw user query without trimming parentheses', () => {
const query = { userQuery: '(cm:name:"test")' }; const query = { userQuery: '(cm:name:"test")' };
expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('cm:name:"test"'); expect(extractUserQueryFromEncodedQuery(encodeQuery(query))).toBe('(cm:name:"test")');
}); });
}); });
describe('extractSearchedWordFromEncodedQuery', () => { describe('extractParsedQueryFromEncodedQuery', () => {
it('should return empty string when encoded query is invalid', () => { it('should return empty string when encoded query is invalid', () => {
const query = { otherProp: 'test' }; expect(extractParsedQueryFromEncodedQuery(null)).toBe('');
expect(extractSearchedWordFromEncodedQuery(null)).toBe(''); expect(extractParsedQueryFromEncodedQuery(undefined)).toBe('');
expect(extractSearchedWordFromEncodedQuery(undefined)).toBe('');
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('');
}); });
it('should properly extract search term', () => { it('should properly extract parsed query', () => {
const query = { userQuery: 'cm:name:"test*"' }; const query = { parsedQuery: '(cm:name:"test*")' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('test'); expect(extractParsedQueryFromEncodedQuery(encodeQuery(query))).toBe('(cm:name:"test*")');
}); });
it('should preserve quotes in search term for custom search', () => { it('should return empty string when parsed query is not present', () => {
const query = { userQuery: '"test"' }; const query = { userQuery: 'cm:name:"test"' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('"test"'); expect(extractParsedQueryFromEncodedQuery(encodeQuery(query))).toBe('');
});
it('should properly extract search term when userQuery does not contain quotes', () => {
const query = { userQuery: 'TEXT:abcdef' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('TEXT:abcdef');
});
it('should properly extract search term when userQuery contains field without quotes', () => {
const query = { userQuery: 'cm:name:searchterm' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('cm:name:searchterm');
});
it('should handle mixed conditions with and without quotes', () => {
const query = { userQuery: 'cm:name:"quoted term" AND TEXT:unquoted' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('quoted term TEXT:unquoted');
});
it('should handle complex search query', () => {
const query = {
userQuery: `((cm:name:"a*" OR cm:title:"a*" OR cm:description:"a*" OR TEXT:"a*" OR TAG:"a*") AND
(cm:name:"b*" OR cm:title:"b*" OR cm:description:"b*" OR TEXT:"b*" OR TAG:"b*") OR
(cm:name:"c*" OR cm:title:"c*" OR cm:description:"c*" OR TEXT:"c*" OR TAG:"c*"))`
};
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('a b OR c');
});
it('should not treat operator as a searched word', () => {
const query = { userQuery: 'AND' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('');
});
it('should not unquote when searching for phrase', () => {
const query = { userQuery: '"exact phrase search"' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('"exact phrase search"');
}); });
}); });
@@ -41,24 +41,12 @@ export function isOperator(input: string): boolean {
* *
* @param term search term * @param term search term
* @param fields array of fields * @param fields array of fields
* @param wildcardsEnabled whether wildcards are enabled
* @returns string * @returns string
*/ */
export function formatSearchTermByFields(term: string, fields: string[]): string { export function formatSearchTermByFields(term: string, fields: string[], wildcardsEnabled = false): string {
let prefix = ''; const suffix = wildcardsEnabled ? '*' : '';
let suffix = '*'; return '(' + fields.map((field) => `${field}:"${term}${suffix}"`).join(' OR ') + ')';
if (term.startsWith('=')) {
prefix = '=';
suffix = '';
term = term.substring(1);
}
if (term === '*') {
prefix = '';
suffix = '';
}
return '(' + fields.map((field) => `${prefix}${field}:"${term}${suffix}"`).join(' OR ') + ')';
} }
/** /**
@@ -66,15 +54,22 @@ export function formatSearchTermByFields(term: string, fields: string[]): string
* *
* @param userInput search term * @param userInput search term
* @param fields array of fields * @param fields array of fields
* @param searchMode regular or formula search mode
* @param wildcardsEnabled whether wildcards are enabled
* @returns string * @returns string
*/ */
export function formatSearchTerm(userInput: string, fields = ['cm:name']): string { export function formatSearchTerm(
userInput: string,
fields = ['cm:name'],
searchMode: 'regular' | 'formula' = 'regular',
wildcardsEnabled = false
): string {
if (!userInput) { if (!userInput) {
return ''; return '';
} }
userInput = userInput.trim(); userInput = userInput.trim();
if (userInput.includes(':') || userInput.includes('"')) { if (searchMode === 'formula') {
return userInput; return userInput;
} }
@@ -82,10 +77,10 @@ export function formatSearchTerm(userInput: string, fields = ['cm:name']): strin
if (words.length > 1) { if (words.length > 1) {
const separator = words.some(isOperator) ? ' ' : ' AND '; const separator = words.some(isOperator) ? ' ' : ' AND ';
return words.map((term) => (isOperator(term) ? term : formatSearchTermByFields(term, fields))).join(separator); return '(' + words.map((term) => (isOperator(term) ? term : formatSearchTermByFields(term, fields, wildcardsEnabled))).join(separator) + ')';
} }
return formatSearchTermByFields(userInput, fields); return '(' + formatSearchTermByFields(userInput, fields, wildcardsEnabled) + ')';
} }
/** /**
@@ -97,66 +92,21 @@ export function formatSearchTerm(userInput: string, fields = ['cm:name']): strin
export function extractUserQueryFromEncodedQuery(encodedQuery: string): string { export function extractUserQueryFromEncodedQuery(encodedQuery: string): string {
if (encodedQuery) { if (encodedQuery) {
const decodedQuery: { [key: string]: any } = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(encodedQuery), (c) => c.charCodeAt(0)))); const decodedQuery: { [key: string]: any } = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(encodedQuery), (c) => c.charCodeAt(0))));
return trimUserQuery(decodedQuery.userQuery); return decodedQuery.userQuery ?? '';
} }
return ''; return '';
} }
/** /**
* Extracts user query from encoded query and splits it to get a search term * Decodes a query and extracts parsed query
* *
* @param encodedQuery encoded query * @param encodedQuery encoded query
* @returns string * @returns string
*/ */
export function extractSearchedWordFromEncodedQuery(encodedQuery: string): string { export function extractParsedQueryFromEncodedQuery(encodedQuery: string): string {
if (!encodedQuery) { if (encodedQuery) {
return ''; const decodedQuery: { [key: string]: any } = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(encodedQuery), (c) => c.charCodeAt(0))));
} return decodedQuery.parsedQuery ?? '';
const userQuery = extractUserQueryFromEncodedQuery(encodedQuery);
if (!userQuery) {
return '';
}
const tokenRegex = /\(([^()]+)\)|\b(AND|OR)\b/g;
const fragments: string[] = [];
let match: RegExpExecArray | null;
while ((match = tokenRegex.exec(userQuery))) {
if (match[1]) {
fragments.push(extractWordFromQuery(match[1]));
} else if (match[2] === 'OR') {
fragments.push('OR');
}
}
if (fragments.length === 0) {
return userQuery
.split(/\bAND\b|\bOR\b/)
.map((part) => extractWordFromQuery(part))
.filter(Boolean)
.join(' ')
.trim();
}
return fragments.join(' ').trim();
}
/**
* Extracts the searched word from a part of search query
*
* @param queryPart encoded query
* @returns searched word
*/
function extractWordFromQuery(queryPart: string): string {
const regex = /:"([^"]+)"/;
const quoted = regex.exec(queryPart);
if (quoted) {
return quoted[1].replace(/\*$/, '');
}
const trimmedPart = queryPart.trim();
if (trimmedPart && !isOperator(trimmedPart)) {
return trimmedPart;
} }
return ''; return '';
} }
@@ -174,14 +124,3 @@ export function extractFiltersFromEncodedQuery(encodedQuery: string): any {
} }
return null; return null;
} }
/**
* Trims one set of parentheses from parsed user query.
*
* @param userQuery user query parsed from encoded query
* @returns string
*/
function trimUserQuery(userQuery: string): string {
const trimmedQuery = userQuery?.replace(/^\(/, '');
return trimmedQuery?.replace(/\)$/, '') ?? '';
}
@@ -31,6 +31,8 @@ export class SearchInputComponent extends BaseComponent {
public searchButton = this.page.locator('.aca-search-input--search-button'); public searchButton = this.page.locator('.aca-search-input--search-button');
public searchCloseButton = this.page.locator('.aca-search-input--close-button'); public searchCloseButton = this.page.locator('.aca-search-input--close-button');
public searchInButton = this.getChild('aca-search-in-menu button'); public searchInButton = this.getChild('aca-search-in-menu button');
public formulaSearchButton = this.getChild('[value="formula"]');
public regularSearchButton = this.getChild('[value="regular"]');
/** /**
* Method used in cases where user have possibility to navigate "inside" the element (it's clickable and has link attribute). * Method used in cases where user have possibility to navigate "inside" the element (it's clickable and has link attribute).
@@ -46,6 +46,7 @@ import { AdfConfirmDialogComponent, AdfFolderDialogComponent, ManageVersionsDial
import { SearchInDialogComponent } from '../components/search/search-in-dialog.components'; import { SearchInDialogComponent } from '../components/search/search-in-dialog.components';
export type SearchType = 'files' | 'folders' | 'filesAndFolders' | 'libraries'; export type SearchType = 'files' | 'folders' | 'filesAndFolders' | 'libraries';
export type SearchMode = 'regular' | 'formula';
export class SearchPage extends BasePage { export class SearchPage extends BasePage {
private static readonly pageUrl = 'search'; private static readonly pageUrl = 'search';
@@ -76,11 +77,17 @@ export class SearchPage extends BasePage {
public folderInformationDialog = new FolderInformationDialogComponent(this.page); public folderInformationDialog = new FolderInformationDialogComponent(this.page);
public searchMenuCard = new SearchMenuCard(this.page); public searchMenuCard = new SearchMenuCard(this.page);
async searchWithin(searchText: string, searchType?: SearchType): Promise<void> { async searchWithin(searchText: string, searchType?: SearchType, searchMode?: SearchMode): Promise<void> {
if (!(await this.searchInputComponent.searchInput.isVisible())) { if (!(await this.searchInputComponent.searchInput.isVisible())) {
await this.acaHeader.searchButton.click(); await this.acaHeader.searchButton.click();
} }
await this.searchInputComponent.searchFor(searchText); await this.searchInputComponent.searchFor(searchText);
await this.dataTable.spinnerWaitForReload();
if (searchMode === 'formula') {
await this.searchInputComponent.formulaSearchButton.click();
} else {
await this.searchInputComponent.regularSearchButton.click();
}
await this.searchInputComponent.searchInButton.click(); await this.searchInputComponent.searchInButton.click();
switch (searchType) { switch (searchType) {
case 'files': case 'files':