[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,
"enableFileAutoDownload": true,
"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
> 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
You can customize the queries to get better results.
Given that, no colon ":" suffixes the term, then the default query is constructed for text searches. The default query is:
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:
```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:
@@ -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.
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.
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.
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.
### Examples
| Search Type | Entered search input value | Expected result | Processed search input value |
| ----------- | -------------------------- | -------------------------------------------------------------------------------- | ---------------------------- |
| Single Term | banana | Nodes that contain the term **banana** in any content | banana |
| Conjunction | big yellow banana | Nodes that contain all of the terms **big**, **yellow**, and **banana** | big yellow banana |
| Phrase | "big yellow banana" | Nodes that contain the exact phrase **big yellow banana** | "big yellow banana" |
| Conjunction | big AND yellow AND banana | Nodes that contain all of the terms **big**, **yellow**, and **banana** | big yellow banana |
| Disjunction | orange OR banana OR apple | Nodes that contain at least one of the terms **orange**, **banana** or **apple** | orange OR banana OR apple |
| Exact term | =orange | Nodes that contain the exact term **orange** in any content. | orange |
| Search Type | Search mode | Entered search input value | Expected result |
| ---------------------- | ----------- | ------------------------------- | -------------------------------------------------------------------------------- |
| Single Term | Standard | banana | Nodes that contain the term **banana** in any configured field |
| Conjunction | Standard | big yellow banana | Nodes that contain all of the terms **big**, **yellow**, and **banana** |
| Conjunction | Standard | big AND yellow AND banana | Nodes that contain all of the terms **big**, **yellow**, and **banana** |
| Disjunction | Standard | orange OR banana OR apple | Nodes that contain at least one of the terms **orange**, **banana** or **apple** |
| Phrase | Formula | cm:name:"big yellow banana" | Nodes whose `cm:name` contains the exact phrase **big yellow banana** |
| 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.
+2 -1
View File
@@ -1,5 +1,6 @@
{
"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) {
await searchPage.searchWithin(searchTerm, 'files');
} else {
await searchPage.searchWithin(`TEXT:${random}`, 'files');
await searchPage.searchWithin(`*${random}`, 'files');
}
await searchPage.searchSortingPicker.sortBy(sortBy, sortOrder);
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,
"dependencies": {
"@alfresco/adf-content-services": "9.1.0-28253465549",
"@alfresco/adf-core": "9.1.0-28253465549",
"@alfresco/adf-extensions": "9.1.0-28253465549",
"@alfresco/eslint-plugin-eslint-angular": "9.1.0-28253465549",
"@alfresco/js-api": "10.1.0-28253465549",
"@angular/animations": "20.3.9",
"@alfresco/adf-content-services": "9.1.0-28667499581",
"@alfresco/adf-core": "9.1.0-28667499581",
"@alfresco/adf-extensions": "9.1.0-28667499581",
"@alfresco/eslint-plugin-eslint-angular": "9.1.0-28667499581",
"@alfresco/js-api": "10.1.0-28667499581",
"@angular/animations": "20.3.25",
"@angular/cdk": "20.2.14",
"@angular/common": "20.3.9",
"@angular/compiler": "20.3.9",
"@angular/core": "20.3.9",
"@angular/forms": "20.3.9",
"@angular/common": "20.3.25",
"@angular/compiler": "20.3.25",
"@angular/core": "20.3.25",
"@angular/forms": "20.3.25",
"@angular/material": "20.2.14",
"@angular/material-date-fns-adapter": "20.2.14",
"@angular/platform-browser": "20.3.9",
"@angular/platform-browser-dynamic": "20.3.9",
"@angular/router": "20.3.9",
"@angular/platform-browser": "20.3.25",
"@angular/platform-browser-dynamic": "20.3.25",
"@angular/router": "20.3.25",
"@fontsource/open-sans": "^5.1.0",
"@mat-datetimepicker/core": "16.0.1",
"@ngrx/effects": "~20.1.0",
@@ -83,7 +83,7 @@
}
},
"devDependencies": {
"@alfresco/adf-cli": "9.1.0-28253465549",
"@alfresco/adf-cli": "9.1.0-28667499581",
"@angular-devkit/build-angular": "20.3.30",
"@angular-devkit/core": "20.3.16",
"@angular-devkit/schematics": "20.3.16",
@@ -92,8 +92,8 @@
"@angular-eslint/eslint-plugin-template": "20.7.0",
"@angular-eslint/schematics": "20.7.0",
"@angular-eslint/template-parser": "20.7.0",
"@angular/compiler-cli": "20.3.9",
"@angular/language-service": "20.3.9",
"@angular/compiler-cli": "20.3.25",
"@angular/language-service": "20.3.25",
"@cspell/eslint-plugin": "^10.0.1",
"@nx/angular": "22.7.6",
"@nx/eslint-plugin": "22.5.4",
@@ -1770,7 +1770,6 @@
}
]
},
"aca:triggeredOnChange": false,
"resetButton": true,
"filterQueries": [
{ "query": "+TYPE:'cm:folder' OR +TYPE:'cm:content'" },
@@ -1991,7 +1990,6 @@
}
]
},
"aca:triggeredOnChange": false,
"resetButton": true,
"filterQueries": [
{ "query": "+TYPE:'cm:folder' OR +TYPE:'cm:content' AND +ASPECT:'cm:dublincore'" },
@@ -2167,7 +2165,6 @@
}
]
},
"aca:triggeredOnChange": false,
"resetButton": true,
"filterQueries": [
{ "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.",
"REQUIRED": "Search input is required.",
"WHITESPACE": "Search input cannot be only whitespace.",
"OPERATORS": "Search input cannot begin with, end with or contain only operators.",
"IN_PREFIX": "In",
"FILES_AND_FOLDERS": "Files and folders",
"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": {
"SORTING_OPTION": "Sort by",
@@ -187,7 +187,8 @@ describe('RuleActionUiComponent', () => {
title: 'ACA_FOLDER_RULES.RULE_DETAILS.PLACEHOLDER.CHOOSE_FOLDER',
actionName: NodeAction.CHOOSE,
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>);
fixture.detectChanges();
@@ -361,7 +362,8 @@ describe('RuleActionUiComponent', () => {
title: 'ACA_FOLDER_RULES.RULE_DETAILS.PLACEHOLDER.CHOOSE_FOLDER',
actionName: NodeAction.CHOOSE,
currentFolderId: 'test-folder-id',
select: jasmine.any(Subject)
select: jasmine.any(Subject),
showFilesInResult: false
},
panelClass: 'adf-content-node-selector-dialog',
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'),
actionName: NodeAction.CHOOSE,
currentFolderId: this.nodeId,
select: new Subject<Node[]>()
select: new Subject<Node[]>(),
showFilesInResult: false
};
this.dialog.open(ContentNodeSelectorComponent, {
@@ -45,7 +45,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { testHeader } from '../../testing/document-base-page-utils';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
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';
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', () => {
it('should return false if node has no aspectNames', () => {
const mock: any = { aspectNames: [] };
@@ -386,7 +386,6 @@ export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
if (activeFilters.length) {
this.showHeader = ShowHeaderMode.Always;
this.isFilterHeaderActive = true;
this.navigateToFilter(activeFilters);
} else {
void this.router.navigate(['.'], { relativeTo: this.route });
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) {
this.isValidPath = false;
if (this.router.url.includes('libraries')) {
@@ -27,7 +27,6 @@ import { Store } from '@ngrx/store';
import { SearchExecutionService } from './search-execution.service';
import { SearchFilterService } from './search-filter.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 { AppStore, SearchActionTypes } from '@alfresco/aca-shared/store';
@@ -36,7 +35,6 @@ describe('SearchExecutionService', () => {
let store: jasmine.SpyObj<Store<AppStore>>;
let filterService: jasmine.SpyObj<SearchFilterService>;
let navigationService: jasmine.SpyObj<SearchNavigationService>;
let queryBuilder: jasmine.SpyObj<SearchQueryBuilderService>;
let queryLibrariesBuilder: jasmine.SpyObj<SearchLibrariesQueryBuilderService>;
beforeEach(() => {
@@ -48,7 +46,6 @@ describe('SearchExecutionService', () => {
onSearchResults: false,
onLibrariesSearchResults: false
});
queryBuilder = jasmine.createSpyObj('SearchQueryBuilderService', ['update']);
queryLibrariesBuilder = jasmine.createSpyObj('SearchLibrariesQueryBuilderService', ['update']);
TestBed.configureTestingModule({
@@ -57,7 +54,6 @@ describe('SearchExecutionService', () => {
{ provide: Store, useValue: store },
{ provide: SearchFilterService, useValue: filterService },
{ provide: SearchNavigationService, useValue: navigationService },
{ provide: SearchQueryBuilderService, useValue: queryBuilder },
{ provide: SearchLibrariesQueryBuilderService, useValue: queryLibrariesBuilder }
]
});
@@ -87,6 +83,24 @@ describe('SearchExecutionService', () => {
service.execute('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', () => {
@@ -25,7 +25,6 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppStore, SearchByTermAction } from '@alfresco/aca-shared/store';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchFilterService } from './search-filter.service';
import { SearchNavigationService } from './search-navigation.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' })
export class SearchExecutionService {
private readonly store = inject<Store<AppStore>>(Store);
private readonly queryBuilder = inject(SearchQueryBuilderService);
private readonly queryLibrariesBuilder = inject(SearchLibrariesQueryBuilderService);
private readonly filterService = inject(SearchFilterService);
private readonly searchNavigationService = inject(SearchNavigationService);
@@ -61,9 +59,7 @@ export class SearchExecutionService {
}
private executeContentSearch(searchedWord: string) {
if (this.searchNavigationService.onSearchResults && this.searchNavigationService.isSameSearchTerm(searchedWord)) {
this.queryBuilder.update();
} else {
if (!(this.searchNavigationService.onSearchResults && this.searchNavigationService.isSameSearchTerm(searchedWord))) {
this.store.dispatch(new SearchByTermAction(searchedWord, this.filterService.searchOptions));
}
}
@@ -81,8 +81,10 @@ describe('SearchFilterService', () => {
expect(service.validateSearchTerm(' ')).toBe('SEARCH.INPUT.WHITESPACE');
});
it('should return error for term starting with operator', () => {
expect(service.validateSearchTerm('+test')).toBe('SEARCH.INPUT.OPERATORS');
it('should not return an operator error for terms containing special characters', () => {
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', () => {
@@ -25,7 +25,6 @@
import { Injectable, inject } from '@angular/core';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchOptionIds, SearchOptionModel } from '@alfresco/aca-shared/store';
import { isOperator } from '../../utils/aca-search-utils';
@Injectable({ providedIn: 'root' })
export class SearchFilterService {
@@ -96,17 +95,10 @@ export class SearchFilterService {
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) {
return 'SEARCH.INPUT.MIN_LENGTH';
}
return null;
}
@@ -17,12 +17,21 @@
[attr.aria-label]="'SEARCH.INPUT.ARIA-LABEL' | translate"
type="text"
[(ngModel)]="searchedWord"
(keydown.enter)="onSearchSubmit(searchedWord)"
(keydown.enter)="onSearchSubmit($event)"
[placeholder]="'SEARCH.INPUT.PLACEHOLDER' | translate"
autocomplete="off"
/>
<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()" />
<button
@@ -1,4 +1,5 @@
@use '@angular/material' as mat;
@use '../../../ui/mat-selectors' as ms;
$search-height: 48px;
$search-border-radius: 4px;
@@ -56,6 +57,20 @@ $search-border-radius: 4px;
display: flex;
align-items: center;
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) {
@@ -26,35 +26,47 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin
import { AppTestingModule } from '../../../testing/app-testing.module';
import { SearchInputComponent } from './search-input.component';
import { Subject } from 'rxjs';
import { ActivatedRoute, Event, NavigationStart, Params, Router } from '@angular/router';
import { SearchConfiguration, SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { ActivatedRoute, Event, Params, Router } from '@angular/router';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchNavigationService } from '../search-navigation.service';
import { SearchFilterService } from '../search-filter.service';
import { SearchExecutionService } from '../search-execution.service';
import { AppHookService } from '@alfresco/aca-shared';
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', () => {
let fixture: ComponentFixture<SearchInputComponent>;
let component: SearchInputComponent;
let router: Router;
let queryBuilder: Partial<SearchQueryBuilderService>;
let searchExecutionService: jasmine.SpyObj<SearchExecutionService>;
let searchFilterService: jasmine.SpyObj<SearchFilterService>;
let searchNavigationService: jasmine.SpyObj<SearchNavigationService>;
let testingUtils: UnitTestingUtils;
let loader: HarnessLoader;
const routerEventsSubject = new Subject<Event>();
const configUpdatedSubject = new Subject<SearchConfiguration>();
const queryParamsSubject = new Subject<Params>();
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 () => {
const queryBuilderSpy = {
configUpdated: configUpdatedSubject,
queryBuilder = {
searchMode: 'regular',
removeFilterQuery: jasmine.createSpy('removeFilterQuery'),
addFilterQuery: jasmine.createSpy('addFilterQuery'),
update: jasmine.createSpy('update')
addFilterQuery: jasmine.createSpy('addFilterQuery')
} as Partial<SearchQueryBuilderService>;
searchExecutionService = jasmine.createSpyObj<SearchExecutionService>('SearchExecutionService', ['execute']);
@@ -83,7 +95,7 @@ describe('SearchInputComponent', () => {
await TestBed.configureTestingModule({
imports: [AppTestingModule, SearchInputComponent, NoopAnimationsModule],
providers: [
{ provide: SearchQueryBuilderService, useValue: queryBuilderSpy },
{ provide: SearchQueryBuilderService, useValue: queryBuilder },
{ provide: SearchExecutionService, useValue: searchExecutionService },
{ provide: SearchFilterService, useValue: searchFilterService },
{ provide: SearchNavigationService, useValue: searchNavigationService },
@@ -100,6 +112,7 @@ describe('SearchInputComponent', () => {
fixture = TestBed.createComponent(SearchInputComponent);
component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});
@@ -134,38 +147,54 @@ describe('SearchInputComponent', () => {
describe('onSearchSubmit', () => {
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');
});
it('should not execute search when search term is empty', () => {
searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY');
component.onSearchSubmit('');
submitSearch('');
expect(searchExecutionService.execute).not.toHaveBeenCalled();
});
it('should not execute search when search term is whitespace', () => {
searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY');
component.onSearchSubmit(' ');
submitSearch(' ');
expect(searchExecutionService.execute).not.toHaveBeenCalled();
});
it('should set error when validation fails', () => {
searchFilterService.validateSearchTerm.and.returnValue('SEARCH.ERRORS.EMPTY_QUERY');
component.onSearchSubmit('');
submitSearch('');
expect(component.error).toBe('SEARCH.ERRORS.EMPTY_QUERY');
});
it('should clear error on valid submission', () => {
component.error = 'some error';
component.onSearchSubmit('valid term');
submitSearch('valid term');
expect(component.error).toBe('');
});
it('should trim whitespace from search term', () => {
component.onSearchSubmit(' hello ');
expect(component.searchedWord).toBe('hello');
expect(searchExecutionService.execute).toHaveBeenCalledWith('hello');
it('should track the trimmed term as the last searched word', () => {
submitSearch(' hello ');
expect(component.lastSearchedWord).toBe('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', () => {
it('should execute search when searchedWord is set and navigation has query params', () => {
component.searchedWord = 'term';
routerEventsSubject.next(new NavigationStart(1, '/path?q=term'));
configUpdatedSubject.next({ id: 'config1' } as SearchConfiguration);
describe('search mode toggle', () => {
const getToggleGroup = (): Promise<MatButtonToggleGroupHarness> =>
loader.getHarness(MatButtonToggleGroupHarness.with({ selector: '.aca-search-input--suffix-button-toggle-group' }));
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', () => {
searchExecutionService.execute.calls.reset();
component.searchedWord = 'term';
routerEventsSubject.next(new NavigationStart(1, '/path'));
configUpdatedSubject.next({} as SearchConfiguration);
expect(searchExecutionService.execute).not.toHaveBeenCalled();
it('should reflect the current queryBuilder search mode', async () => {
const group = await getToggleGroup();
const toggles = await group.getToggles();
expect(await toggles[0].isChecked()).toBeTrue();
expect(await toggles[1].isChecked()).toBeFalse();
});
it('should NOT execute search when searchedWord is not set', () => {
searchExecutionService.execute.calls.reset();
component.searchedWord = null;
routerEventsSubject.next(new NavigationStart(1, '/path?q=term'));
configUpdatedSubject.next({} as SearchConfiguration);
expect(searchExecutionService.execute).not.toHaveBeenCalled();
it('should update queryBuilder search mode to formula when formula toggle is selected', async () => {
const group = await getToggleGroup();
const toggles = await group.getToggles();
await toggles[1].check();
expect(queryBuilder.searchMode).toBe('formula');
});
});
@@ -23,9 +23,8 @@
*/
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 { ActivatedRoute, NavigationSkipped, NavigationStart, Params, Router } from '@angular/router';
import { ActivatedRoute, NavigationSkipped, Params, Router } from '@angular/router';
import { SearchNavigationService } from '../search-navigation.service';
import { SearchFilterService } from '../search-filter.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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
@Component({
imports: [CommonModule, TranslatePipe, MatButtonModule, MatIconModule, MatFormFieldModule, MatInputModule, FormsModule, SearchInMenuComponent],
imports: [
CommonModule,
TranslatePipe,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
SearchInMenuComponent,
MatButtonToggleModule
],
selector: 'aca-search-input',
templateUrl: './search-input.component.html',
styleUrls: ['./search-input.component.scss'],
@@ -52,18 +62,17 @@ import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-u
host: { class: 'aca-search-input' }
})
export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly queryBuilder = inject(SearchQueryBuilderService);
private readonly config = inject(AppConfigService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly appHookService = inject(AppHookService);
private readonly filterService = inject(SearchFilterService);
private readonly searchExecutionService = inject(SearchExecutionService);
readonly searchNavigationService = inject(SearchNavigationService);
readonly queryBuilder = inject(SearchQueryBuilderService);
has400LibraryError = false;
searchOnChange: boolean;
searchedWord: string = null;
lastSearchedWord: string = null;
error = '';
@ViewChild('searchInputField')
@@ -71,10 +80,6 @@ export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
constructor() {
this.searchOnChange = this.config.get<boolean>('search.aca:triggeredOnChange', true);
}
ngOnInit(): void {
this.initSearchState();
this.subscribeToRouteParams();
@@ -98,8 +103,8 @@ export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
this.searchNavigationService.navigateBack();
}
onSearchSubmit(searchTerm: string) {
const trimmedTerm = searchTerm?.trim();
onSearchSubmit(event: Event) {
const trimmedTerm = (event.target as HTMLInputElement).value?.trim();
const validationError = this.filterService.validateSearchTerm(trimmedTerm);
if (validationError) {
@@ -107,9 +112,12 @@ export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
return;
}
this.searchedWord = trimmedTerm;
this.error = '';
this.executeSearch();
if (this.lastSearchedWord !== trimmedTerm) {
this.lastSearchedWord = trimmedTerm;
this.searchedWord = trimmedTerm;
this.executeSearch();
}
}
onFiltersApplied() {
@@ -143,24 +151,7 @@ export class SearchInputComponent implements OnInit, AfterViewInit, OnDestroy {
.subscribe((params: Params) => {
const encodedQuery = params['q'];
if (encodedQuery) {
this.searchedWord = extractSearchedWordFromEncodedQuery(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);
this.searchedWord = extractUserQueryFromEncodedQuery(encodedQuery);
}
});
}
@@ -75,8 +75,8 @@ describe('SearchLibrariesResultsComponent', () => {
expect(component.onSearchResultLoaded).toHaveBeenCalledWith(emptyPage);
});
it('should extract searched word from query params', (done) => {
route.queryParams = of({ q: encodeQuery({ userQuery: 'cm:name:"test*"' }) });
it('should extract the user query from query params', (done) => {
route.queryParams = of({ q: encodeQuery({ userQuery: 'test' }) });
route.queryParams.subscribe(() => {
fixture.detectChanges();
expect(component.searchedWord).toBe('test');
@@ -44,7 +44,7 @@ import { CustomEmptyContentTemplateDirective, DataColumnComponent, DataColumnLis
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { DocumentListDirective } from '../../../directives/document-list.directive';
import { DocumentListComponent } from '@alfresco/adf-content-services';
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
import { extractUserQueryFromEncodedQuery } from '../../../utils/aca-search-utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
@@ -129,7 +129,7 @@ export class SearchLibrariesResultsComponent extends PageComponent implements On
if (this.route) {
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => {
const encodedQuery = params[this.queryParamName] || null;
this.searchedWord = extractSearchedWordFromEncodedQuery(encodedQuery);
this.searchedWord = extractUserQueryFromEncodedQuery(encodedQuery);
if (this.searchedWord?.length > 1) {
this.librariesQueryBuilder.paging.skipCount = 0;
this.librariesQueryBuilder.userQuery = this.searchedWord;
@@ -26,11 +26,14 @@ import { TestBed } from '@angular/core/testing';
import { SearchNavigationService } from './search-navigation.service';
import { Router } from '@angular/router';
import { AppTestingModule } from '../../testing/app-testing.module';
import { Buffer } from 'buffer';
describe('SearchNavigationService', () => {
let service: SearchNavigationService;
let router: Router;
const encodeQuery = (query: any): string => Buffer.from(JSON.stringify(query)).toString('base64');
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule]
@@ -69,4 +72,31 @@ describe('SearchNavigationService', () => {
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 { PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { extractSearchedWordFromEncodedQuery } from '../../utils/aca-search-utils';
import { extractUserQueryFromEncodedQuery } from '../../utils/aca-search-utils';
@Injectable({
providedIn: 'root'
@@ -73,7 +73,7 @@ export class SearchNavigationService {
if (urlSegmentGroup) {
const urlSegments: UrlSegment[] = urlSegmentGroup.segments;
return extractSearchedWordFromEncodedQuery(urlSegments[0].parameters['q']);
return extractUserQueryFromEncodedQuery(urlSegments[0].parameters['q']);
}
return '';
@@ -61,7 +61,6 @@ describe('SearchComponent', () => {
let showErrorSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
let showInfoSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
let loader: HarnessLoader;
let updatedSubjectMock: Subject<SearchRequest>;
const editSavedSearchesSpy = jasmine.createSpy('editSavedSearch');
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' });
queryParams = new Subject();
routerEvents = new Subject();
updatedSubjectMock = new Subject();
const routerMock = jasmine.createSpyObj<Router>('Router', ['navigate'], {
url: '/mock-search-url',
@@ -128,8 +126,6 @@ describe('SearchComponent', () => {
router = TestBed.inject(Router);
route = TestBed.inject(ActivatedRoute);
queryBuilder.updated = updatedSubjectMock;
const notificationService = TestBed.inject(NotificationService);
showErrorSpy = spyOn(notificationService, 'showError');
showInfoSpy = spyOn(notificationService, 'showInfo');
@@ -141,8 +137,6 @@ describe('SearchComponent', () => {
fixture = TestBed.createComponent(SearchResultsComponent);
component = fixture.componentInstance;
spyOn(queryBuilder, 'update').and.stub();
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
});
@@ -232,6 +226,7 @@ describe('SearchComponent', () => {
});
it('should re-run search on pagination change', () => {
const executeSpy = spyOn(queryBuilder, 'execute');
const page = new Pagination({
maxItems: 10,
skipCount: 0
@@ -243,25 +238,60 @@ describe('SearchComponent', () => {
maxItems: 10,
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) => {
spyOn(queryBuilder.populateFilters, 'next');
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) });
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' } });
expect(queryBuilder.userQuery).toBe(`orange`);
expect(queryBuilder.populateFilters.next).toHaveBeenCalledWith({ userQuery: 'orange', filterProp: { prop: 'test' } });
queryBuilder.filterLoaded.next();
fixture.detectChanges();
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', () => {
route.queryParams = of({ q: encodeQuery({ name: 'test' }) });
component.ngOnInit();
@@ -370,30 +400,18 @@ describe('SearchComponent', () => {
expect(executeSpy).toHaveBeenCalledTimes(1);
}));
it('should format userQuery when url parameters changed and userQuery is not contained by url', () => {
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', () => {
it('should set the raw userQuery from the encoded query when url parameters change', () => {
routerEvents.next(new NavigationStart(1, ''));
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();
expect(component.isLoading).toBeFalse();
updatedSubjectMock.next(null);
tick();
expect(component.isLoading).toBeFalse();
updatedSubjectMock.next({} as SearchRequest);
queryBuilder.filterQueryUpdate.next();
tick();
@@ -30,7 +30,6 @@ import {
DocumentListComponent,
ResetSearchDirective,
SavedSearch,
SearchConfiguration,
SearchFilterChipsComponent,
SearchFormComponent,
SearchSortingDefinition,
@@ -77,12 +76,7 @@ import { MatIconModule } from '@angular/material/icon';
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-actions-dropdown.component';
import { SearchAiInputContainerComponent } from '../../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
import {
extractFiltersFromEncodedQuery,
extractSearchedWordFromEncodedQuery,
extractUserQueryFromEncodedQuery,
formatSearchTerm
} from '../../../utils/aca-search-utils';
import { extractFiltersFromEncodedQuery, extractUserQueryFromEncodedQuery } from '../../../utils/aca-search-utils';
import { SaveSearchDirective } from '../search-save/directive/save-search.directive';
import { combineLatest, Observable, of } from 'rxjs';
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 translationService = inject(TranslationService);
private readonly savedSearchesService = inject(SavedSearchesContextService);
private readonly notificationService = inject(NotificationService);
infoDrawerPreview$ = this.store.select(infoDrawerPreview);
@@ -154,7 +147,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
initialSavedSearch: SavedSearch = undefined;
columns: DocumentListPresetRef[] = [];
encodedQuery: string;
searchConfig: SearchConfiguration;
isSmallScreen = window.innerWidth < 320;
private previousEncodedQuery: string;
@@ -175,10 +167,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
maxItems: 25
};
this.queryBuilder.configUpdated.pipe(takeUntilDestroyed()).subscribe((searchConfig) => {
this.searchConfig = searchConfig;
});
this.areFiltersActive$ = combineLatest([this.queryBuilder.queryFragmentsUpdate, this.queryBuilder.userFacetBucketsUpdate]).pipe(
takeUntilDestroyed(),
map((filters) => {
@@ -197,12 +185,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
this.sorting = this.getSorting();
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.paging.skipCount = 0;
@@ -237,13 +219,16 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
.pipe(
takeUntilDestroyed(this.destroyRef),
tap(([params]) => {
this.queryBuilder.userQuery = '';
this.encodedQuery = params[this.queryParamName];
this.isLoading = !!this.encodedQuery;
this.searchedWord = extractSearchedWordFromEncodedQuery(this.encodedQuery);
this.searchedWord = extractUserQueryFromEncodedQuery(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 || {});
}),
switchMap(([, navigationStartEvent]) => {
@@ -257,9 +242,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
.subscribe((navigationStartEvent) => {
const shouldExecuteQuery = this.shouldExecuteQuery(navigationStartEvent, 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) {
this.queryBuilder.execute(false);
@@ -311,7 +293,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
maxItems: pagination.maxItems,
skipCount: pagination.skipCount
};
this.queryBuilder.update();
this.queryBuilder.execute(false);
}
private getSorting(): string[] {
@@ -350,7 +332,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
onSearchSortingUpdate(option: SearchSortingDefinition) {
this.queryBuilder.sorting = [{ ...option, ascending: option.ascending }];
this.queryBuilder.update();
this.queryBuilder.execute(false);
}
editSavedSearch(searchToSave: SavedSearch) {
@@ -381,9 +363,15 @@ export class SearchResultsComponent extends PageComponent implements OnInit, OnD
const hasQueryChanged = query !== this.previousEncodedQuery;
this.previousEncodedQuery = query;
if (!navigationStartEvent || navigationStartEvent.navigationTrigger === 'popstate' || navigationStartEvent.navigationTrigger === 'hashchange') {
if (!navigationStartEvent && this.data === undefined) {
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;
} else {
return !!query;
@@ -255,7 +255,8 @@ export class NodeActionsService {
isSelectionValid: this.canCopyMoveInsideIt.bind(this),
breadcrumbTransform: this.customizeBreadcrumb.bind(this),
select: new Subject<Node[]>(),
excludeSiteContent: ContentNodeDialogService.nonDocumentSiteContent
excludeSiteContent: ContentNodeDialogService.nonDocumentSiteContent,
showFilesInResult: false
};
}
@@ -77,7 +77,8 @@ export class NodeTemplateService {
showSearch: false,
showDropdownSiteList: false,
isSelectionValid: this.isSelectionValid.bind(this),
rowFilter: this.rowFilter.bind(this)
rowFilter: this.rowFilter.bind(this),
showFilesInResult: false
};
const query = {
@@ -50,14 +50,14 @@ describe('SearchEffects', () => {
});
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');
store.dispatch(new SearchByTermAction('test', []));
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');
store.dispatch(
new SearchByTermAction('test', [
@@ -72,7 +72,7 @@ describe('SearchEffects', () => {
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 { SearchNavigationService } from '../../components/search/search-navigation.service';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { formatSearchTerm } from '../../utils/aca-search-utils';
@Injectable()
export class SearchEffects {
@@ -52,10 +51,9 @@ export class SearchEffects {
this.actions$.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
const query = formatSearchTerm(action.payload, this.queryBuilder.config['app:fields']);
const libItem = action.searchOptions.find((item) => item.id === SearchOptionIds.Libraries);
const librarySelected = !!libItem && libItem.value;
this.queryBuilder.navigateToSearch(query, librarySelected ? '/search-libraries' : '/search');
this.queryBuilder.navigateToSearch(action.payload, librarySelected ? '/search-libraries' : '/search');
})
),
{ dispatch: false }
@@ -24,7 +24,7 @@
import {
extractFiltersFromEncodedQuery,
extractSearchedWordFromEncodedQuery,
extractParsedQueryFromEncodedQuery,
extractUserQueryFromEncodedQuery,
formatSearchTerm,
formatSearchTermByFields,
@@ -62,20 +62,20 @@ describe('SearchUtils', () => {
});
describe('formatSearchTermByFields', () => {
it('should append "*" to search term', () => {
expect(formatSearchTermByFields('test', ['name'])).toBe('(name:"test*")');
it('should not append wildcard by default', () => {
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 append "*" to search term when wildcards are enabled', () => {
expect(formatSearchTermByFields('test', ['name'], true)).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*")');
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('');
});
it('should not transfer custom queries', () => {
expect(formatSearchTerm('test:"term"')).toBe('test:"term"');
expect(formatSearchTerm('"test"')).toBe('"test"');
it('should return the raw input untouched in formula mode', () => {
expect(formatSearchTerm('test:"term"', ['cm:name'], 'formula')).toBe('test:"term"');
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', () => {
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(
'(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', () => {
expect(formatSearchTerm('hello')).toBe(`(cm:name:"hello*")`);
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*")`
`((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")`
`((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 split words correctly when multiple whitespaces are present', () => {
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"');
});
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")' };
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', () => {
const query = { otherProp: 'test' };
expect(extractSearchedWordFromEncodedQuery(null)).toBe('');
expect(extractSearchedWordFromEncodedQuery(undefined)).toBe('');
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('');
expect(extractParsedQueryFromEncodedQuery(null)).toBe('');
expect(extractParsedQueryFromEncodedQuery(undefined)).toBe('');
});
it('should properly extract search term', () => {
const query = { userQuery: 'cm:name:"test*"' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('test');
it('should properly extract parsed query', () => {
const query = { parsedQuery: '(cm:name:"test*")' };
expect(extractParsedQueryFromEncodedQuery(encodeQuery(query))).toBe('(cm:name:"test*")');
});
it('should preserve quotes in search term for custom search', () => {
const query = { userQuery: '"test"' };
expect(extractSearchedWordFromEncodedQuery(encodeQuery(query))).toBe('"test"');
});
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"');
it('should return empty string when parsed query is not present', () => {
const query = { userQuery: 'cm:name:"test"' };
expect(extractParsedQueryFromEncodedQuery(encodeQuery(query))).toBe('');
});
});
@@ -41,24 +41,12 @@ export function isOperator(input: string): boolean {
*
* @param term search term
* @param fields array of fields
* @param wildcardsEnabled whether wildcards are enabled
* @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 ') + ')';
export function formatSearchTermByFields(term: string, fields: string[], wildcardsEnabled = false): string {
const suffix = wildcardsEnabled ? '*' : '';
return '(' + fields.map((field) => `${field}:"${term}${suffix}"`).join(' OR ') + ')';
}
/**
@@ -66,15 +54,22 @@ export function formatSearchTermByFields(term: string, fields: string[]): string
*
* @param userInput search term
* @param fields array of fields
* @param searchMode regular or formula search mode
* @param wildcardsEnabled whether wildcards are enabled
* @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) {
return '';
}
userInput = userInput.trim();
if (userInput.includes(':') || userInput.includes('"')) {
if (searchMode === 'formula') {
return userInput;
}
@@ -82,10 +77,10 @@ export function formatSearchTerm(userInput: string, fields = ['cm:name']): strin
if (words.length > 1) {
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 {
if (encodedQuery) {
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 '';
}
/**
* 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
* @returns string
*/
export function extractSearchedWordFromEncodedQuery(encodedQuery: string): string {
if (!encodedQuery) {
return '';
}
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;
export function extractParsedQueryFromEncodedQuery(encodedQuery: string): string {
if (encodedQuery) {
const decodedQuery: { [key: string]: any } = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(encodedQuery), (c) => c.charCodeAt(0))));
return decodedQuery.parsedQuery ?? '';
}
return '';
}
@@ -174,14 +124,3 @@ export function extractFiltersFromEncodedQuery(encodedQuery: string): any {
}
return null;
}
/**
* Trims one set of parentheses from parsed user query.
*
* @param userQuery user query parsed from encoded query
* @returns string
*/
function trimUserQuery(userQuery: string): string {
const trimmedQuery = userQuery?.replace(/^\(/, '');
return trimmedQuery?.replace(/\)$/, '') ?? '';
}
@@ -31,6 +31,8 @@ export class SearchInputComponent extends BaseComponent {
public searchButton = this.page.locator('.aca-search-input--search-button');
public searchCloseButton = this.page.locator('.aca-search-input--close-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).
@@ -46,6 +46,7 @@ import { AdfConfirmDialogComponent, AdfFolderDialogComponent, ManageVersionsDial
import { SearchInDialogComponent } from '../components/search/search-in-dialog.components';
export type SearchType = 'files' | 'folders' | 'filesAndFolders' | 'libraries';
export type SearchMode = 'regular' | 'formula';
export class SearchPage extends BasePage {
private static readonly pageUrl = 'search';
@@ -76,11 +77,17 @@ export class SearchPage extends BasePage {
public folderInformationDialog = new FolderInformationDialogComponent(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())) {
await this.acaHeader.searchButton.click();
}
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();
switch (searchType) {
case 'files':