From d8b36606e2cb32a31959816a01b488e957c5e1bc Mon Sep 17 00:00:00 2001 From: Michal Kinas <113341662+MichalKinas@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:46:30 +0200 Subject: [PATCH] [MNT-25681] Search refactoring and unification (#12019) * [MNT-25681] Search refactoring and unification * [MNT-25681] CR fixes --- .../components/search-text.component.md | 6 +- .../interfaces/search-widget.interface.md | 9 +- .../services/search-query-builder.service.md | 68 +++- docs/user-guide/search-building.md | 5 +- docs/user-guide/search-configuration-guide.md | 39 ++ ...de-selector-panel.component-search.spec.ts | 162 ++++---- ...tent-node-selector-panel.component.spec.ts | 3 - .../content-node-selector-panel.component.ts | 49 +-- .../filter-header.component.spec.ts | 38 +- .../filter-header/filter-header.component.ts | 13 +- .../search-check-list.component.spec.ts | 6 +- .../search-check-list.component.ts | 6 +- ...-chip-autocomplete-input.component.spec.ts | 7 + ...earch-chip-autocomplete-input.component.ts | 6 +- ...search-date-range-tabbed.component.spec.ts | 8 +- .../search-date-range-tabbed.component.ts | 2 +- .../search-datetime-range.component.spec.ts | 8 +- .../search-datetime-range.component.ts | 4 +- .../search-facet-field.component.spec.ts | 20 +- .../search-facet-field.component.ts | 10 +- ...ilter-autocomplete-chips.component.spec.ts | 6 +- ...rch-filter-autocomplete-chips.component.ts | 3 +- ...rch-facet-tabbed-content.component.spec.ts | 12 +- .../search-facet-tabbed-content.component.ts | 4 +- .../search-facet-chip.component.spec.ts | 6 +- .../search-filter-chips.component.spec.ts | 12 +- .../search-widget-chip.component.spec.ts | 6 +- .../search-filter.component.spec.ts | 16 +- .../search-logical-filter.component.spec.ts | 14 +- .../search-logical-filter.component.ts | 4 +- .../search-number-range.component.spec.ts | 6 +- .../search-number-range.component.ts | 6 +- .../search-properties.component.spec.ts | 22 +- .../search-properties.component.ts | 4 +- .../search-radio.component.spec.ts | 11 +- .../search-radio/search-radio.component.ts | 5 +- .../search-slider.component.spec.ts | 8 +- .../search-slider/search-slider.component.ts | 2 +- .../search-text/search-text.component.spec.ts | 76 +++- .../search-text/search-text.component.ts | 9 +- .../base-query-builder.service.spec.ts | 351 +++++++++++++----- .../services/base-query-builder.service.ts | 180 +++++---- .../search-facet-filters.service.spec.ts | 6 +- .../services/search-facet-filters.service.ts | 10 +- ...earch-header-query-builder.service.spec.ts | 20 +- .../search-header-query-builder.service.ts | 5 - .../search-query-builder.service.spec.ts | 62 ++-- 47 files changed, 882 insertions(+), 453 deletions(-) diff --git a/docs/content-services/components/search-text.component.md b/docs/content-services/components/search-text.component.md index 52531deb43..21eac80b27 100644 --- a/docs/content-services/components/search-text.component.md +++ b/docs/content-services/components/search-text.component.md @@ -46,9 +46,9 @@ Implements a [search widget](../../../lib/content-services/src/lib/search/models | field | string | Field to apply the query fragment to. Required value | | pattern | string | Regular expression pattern to restrict the format of the input text | | placeholder | string | Text displayed in the widget when the input string is empty | -| searchSuffix | string | Text to append always in the search of a string | -| searchPrefix | string | Text to prepend always in the search of a string | -| allowUpdateOnChange | `boolean` | Enable/Disable the update fire event when text has been changed. By default is true. | +| searchSuffix | string | Text to append in the search of a string. Only applied when wildcard matching is enabled (the `search-wildcards-enabled` app config flag, default `true`). | +| searchPrefix | string | Text to prepend in the search of a string. Only applied when wildcard matching is enabled (the `search-wildcards-enabled` app config flag, default `true`). | +| allowUpdateOnChange | `boolean` | Enable/Disable firing the search update when the text changes. Defaults to `false`; when disabled the search runs only when the user submits the value. | | hideDefaultAction | boolean | Show/hide the widget actions. By default is false. | ## Details diff --git a/docs/content-services/interfaces/search-widget.interface.md b/docs/content-services/interfaces/search-widget.interface.md index 8ce60c716d..3adab3e76c 100644 --- a/docs/content-services/interfaces/search-widget.interface.md +++ b/docs/content-services/interfaces/search-widget.interface.md @@ -115,8 +115,8 @@ that will be used when performing the actual query. Every query fragment is stored and retrieved using its widget `id`. It is your responsibility to format the query correctly. -Once your change to the query is finished, update the context and call the `update` method -to inform other components about the change: +Once your change to the query is finished, update the context and call the `execute` method +to rebuild and run the query so the results reflect your change: ```ts @Component({...}) @@ -126,12 +126,15 @@ export class MyComponent implements SearchWidget, OnInit { onUIChanged() { this.context.queryFragments[this.id] = `some query`; - this.context.update(); + void this.context.execute(); } } ``` +> **Note:** Earlier versions called `this.context.update()` here. The `update()` method and the +> `updated` stream have been removed; call `this.context.execute()` directly instead. + When executed, your fragment will be injected into the resulting query based on the category order in the application configuration file. ```text diff --git a/docs/content-services/services/search-query-builder.service.md b/docs/content-services/services/search-query-builder.service.md index a6d2c8b335..e1e583ddb2 100644 --- a/docs/content-services/services/search-query-builder.service.md +++ b/docs/content-services/services/search-query-builder.service.md @@ -2,7 +2,7 @@ Title: Search Query Builder service Added: v2.3.0 Status: Active -Last reviewed: 2019-03-19 +Last reviewed: 2026-06-29 --- # [Search Query Builder service](../../../lib/content-services/src/lib/search/services/search-query-builder.service.ts "Defined in search-query-builder.service.ts") @@ -11,6 +11,17 @@ Stores information from all the custom search and faceted search widgets, compil ## Class members +### Properties + +| Name | Type | Description | +| ----------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| userQuery | `string` | The raw query string typed by the user. Setting it stores the value, records it in `filterRawParams` and recomputes `parsedQuery` according to the current `searchMode`. | +| parsedQuery | `string` (read-only) | The query derived from `userQuery`. In `regular` mode the user terms are expanded against the configured fields (see `app:fields`) and optionally wildcarded; in `formula` mode it is identical to `userQuery`. | +| searchMode | `'regular' \| 'formula'` | Controls how `userQuery` is turned into `parsedQuery`. `regular` (default) parses the user input into a field query; `formula` uses the user input verbatim as an AFTS expression. | +| selectedConfigurationId | `string` | Id of the currently active search configuration. Setting it also records the value in `filterRawParams`. | +| encodedQuery | `string` (read-only) | The Base64-encoded `filterRawParams`, produced by `encodeQuery()` and written to the `q` route query parameter. | +| wildcardsEnabled | `boolean` (read-only) | Reads the `search-wildcards-enabled` app config flag (default `true`). When enabled, query terms are suffixed with `*` so partial matches are returned. | + ### Methods - **addFilterQuery**(query: `string`)
@@ -25,9 +36,10 @@ Stores information from all the custom search and faceted search widgets, compil - **Returns** `SearchRequest` - The finished query - **encodeQuery**()
Encodes query shards stored in `filterRawParams` property. -- **execute**(queryBody?: `SearchRequest`)
- Builds and executes the current query. - - _queryBody:_ `SearchRequest` - (Optional) +- **execute**(updateQueryParams: `boolean` = `true`, queryBody?: `SearchRequest`)
+ Builds and executes the current query, then emits the result on the `executed` stream. + - _updateQueryParams:_ `boolean` - (Optional) When `true` (default) the encoded query is written to the `q` route query parameter. Pass `false` to execute without updating the URL. + - _queryBody:_ `SearchRequest` - (Optional) Pre-built query to execute instead of building one from the current state. - **getDefaultConfiguration**(): [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)`|undefined`
- **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)`|undefined` - @@ -70,6 +82,11 @@ Stores information from all the custom search and faceted search widgets, compil - **Returns** `boolean` - +- **isOperator**(input: `string`): `boolean`
+ Checks whether the supplied string is a logical `AND` or `OR` operator. Used when parsing a multi-word user query in `regular` search mode. + - _input:_ `string` - String to check + - **Returns** `boolean` - `true` if the trimmed string is `AND` or `OR`, otherwise `false` + - **loadConfiguration**(): [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)
- **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts) - @@ -86,7 +103,10 @@ Stores information from all the custom search and faceted search widgets, compil Removes an existing bucket from a field. - _field:_ [`FacetField`](../../../lib/content-services/src/lib/search/models/facet-field.interface.ts) - The target field - _bucket:_ [`FacetFieldBucket`](../../../lib/content-services/src/lib/search/models/facet-field-bucket.interface.ts) - Bucket to remove -- **resetToDefaults**()
+- **resetToDefaults**(withNavigate: `boolean` = `false`, resetUserQuery: `boolean` = `true`)
+ Resets the query builder back to the default search configuration. + - _withNavigate:_ `boolean` - (Optional) When `true`, clears the `q` route query parameter while resetting. Defaults to `false`. + - _resetUserQuery:_ `boolean` - (Optional) When `true` (default), the `userQuery` and its parsed form are cleared. Pass `false` to keep the current user query while resetting the rest of the options. - **search**(queryBody: `SearchRequest`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>`
@@ -97,14 +117,13 @@ Stores information from all the custom search and faceted search widgets, compil - _scope:_ `RequestScope` - -- **update**(queryBody?: `SearchRequest`)
- Builds the current query and triggers the `updated` event. - - _queryBody:_ `SearchRequest` - (Optional) - **updateSearchQueryParams**()
Encodes the query and navigates to existing search route adding encoded query as a search param. -- **updateSelectedConfiguration**(index: `number`)
- - - _index:_ `number` - +- **updateSelectedConfiguration**(id: `string`, resetFilters: `boolean` = `true`, shouldExecute: `boolean` = `true`)
+ Switches the active search configuration to the one matching the supplied id (only relevant when multiple configurations are provided). + - _id:_ `string` - Id of the configuration to select + - _resetFilters:_ `boolean` - (Optional) When `true` (default), the current search options are reset before applying the new configuration. Pass `false` to keep them. + - _shouldExecute:_ `boolean` - (Optional) When `true` (default), the query is executed immediately after switching configuration. ## Details @@ -127,10 +146,6 @@ You can use custom widgets to populate and edit the following parts of the resul ```ts constructor(queryBuilder: SearchQueryBuilderService) { - queryBuilder.updated.subscribe(query => { - this.queryBuilder.execute(); - }); - queryBuilder.executed.subscribe(data => { this.onDataLoaded(data); }); @@ -138,6 +153,29 @@ constructor(queryBuilder: SearchQueryBuilderService) { } ``` +To run a search, build the query state (for example by setting `userQuery` or by letting a +search widget update `queryFragments`) and then call `execute()`. The result is delivered +through the `executed` stream. + +```ts +this.queryBuilder.userQuery = 'invoice'; +void this.queryBuilder.execute(); +``` + +> **Note:** Earlier versions exposed an `updated` stream and an `update()` method that built the +> query and emitted it so that a subscriber could call `execute()`. Both have been removed; build +> the query state and call `execute()` directly instead. + +### Search modes + +The builder supports two search modes, selected through the `searchMode` property: + +- **regular** (default) - the text in `userQuery` is treated as user input and parsed into a + field query. The query is split into terms, each term is matched against the fields listed in + the `app:fields` search configuration entry (falling back to `cm:name`), and a `*` wildcard is + appended when wildcards are enabled. Bare `AND`/`OR` words are preserved as operators. +- **formula** - the text in `userQuery` is used verbatim as an [AFTS](https://docs.alfresco.com/content-services/latest/develop/search-api/) expression, allowing callers that already build their own query syntax to bypass parsing. + > **Note:** From ADF 3.0.0, the query contains the `"facetFormat": "V2"` parameter so that all the responses have the same structure whether they come from search queries containing facetFields, facetQueries, grouped facetQueries or facetIntervals. ## Runtime Configuration diff --git a/docs/user-guide/search-building.md b/docs/user-guide/search-building.md index 344b4897cf..7b44f64e73 100644 --- a/docs/user-guide/search-building.md +++ b/docs/user-guide/search-building.md @@ -90,7 +90,6 @@ export class YourSearchComponent implements OnInit { ngOnInit() { this.queryBuilder.resetToDefaults(); - this.queryBuilder.updated.subscribe(() => void this.queryBuilder.execute()); this.queryBuilder.executed.subscribe((data) => { this.queryBuilder.paging.skipCount = 0; this.onSearchResultLoaded(data); @@ -103,7 +102,7 @@ export class YourSearchComponent implements OnInit { onSearchQueryChanged(string: string) { this.queryBuilder.userQuery = decodeURIComponent(string); - this.queryBuilder.update(); + void this.queryBuilder.execute(); } async onPaginationChanged(pagination: Pagination) { @@ -111,7 +110,7 @@ export class YourSearchComponent implements OnInit { maxItems: pagination.maxItems, skipCount: pagination.skipCount }; - this.queryBuilder.update(); + void this.queryBuilder.execute(); } } ``` diff --git a/docs/user-guide/search-configuration-guide.md b/docs/user-guide/search-configuration-guide.md index ff431bd378..946c319a97 100644 --- a/docs/user-guide/search-configuration-guide.md +++ b/docs/user-guide/search-configuration-guide.md @@ -16,6 +16,7 @@ This page describes how you can configure the search configuration. - [Steps Involved In Search Configuration](#steps-involved-in-search-configuration) - [Configuration](#configuration) - [Extra fields and filter queries](#extra-fields-and-filter-queries) +- [Search modes and wildcards](#search-modes-and-wildcards) - [Sorting](#sorting) - [Categories and widgets](#categories-and-widgets) - [Facet Fields](#facet-fields) @@ -267,6 +268,44 @@ settings: Note that the entries of the `filterQueries` array are joined using the `AND` operator. +### Search modes and wildcards + +When a user types free text into a search input, the [Search Query Builder Service](../../content-services/services/search-query-builder.service.md) turns that text (its `userQuery`) into the final query according to the configured *search mode*: + +- **regular** (default) - the user input is parsed into a field query. Each term is matched + against the fields listed in the `app:fields` array (falling back to `cm:name` when it is not + set), and a `*` wildcard is appended to each term when wildcards are enabled. Words that are + exactly `AND` or `OR` are preserved as logical operators. +- **formula** - the user input is passed through verbatim as an + [AFTS](https://docs.alfresco.com/content-services/latest/develop/search-api/) expression. Use + this mode when the caller already builds valid query syntax. + +The `app:fields` entry lists the fields used to expand a `regular` user query: + +```json +{ + "search": { + ... + "app:fields": ["cm:name", "cm:title", "cm:description"] + ... + } +} +``` + +For example, with the configuration above and wildcards enabled, the user query `report` is +expanded to `(cm:name:"report*" OR cm:title:"report*" OR cm:description:"report*")`. + +Wildcard matching is controlled by the top-level `search-wildcards-enabled` flag in +`app.config.json` (default `true`). When set to `false`, terms are matched exactly and the +trailing `*` is not added (this also disables the `searchPrefix`/`searchSuffix` of the +[Search text component](../content-services/components/search-text.component.md)): + +```json +{ + "search-wildcards-enabled": false +} +``` + ### Sorting The Sorting configuration section consists of two blocks: diff --git a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component-search.spec.ts b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component-search.spec.ts index c2ea758720..f63894769d 100644 --- a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component-search.spec.ts +++ b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component-search.spec.ts @@ -26,7 +26,6 @@ import { DocumentListComponent } from '../../document-list/components/document-l import { CustomResourcesService } from '../../document-list/services/custom-resources.service'; import { NodeEntryEvent, ShareDataRow } from '../../document-list'; import { SearchQueryBuilderService } from '../../search'; -import { mockSearchRequest } from '../../mock/search-query.mock'; import { SitesService } from '../../common/services/sites.service'; import { NodesApiService } from '../../common/services/nodes-api.service'; import { UnitTestingUtils } from '../../../../../core/src/lib/testing/unit-testing-utils'; @@ -161,43 +160,70 @@ describe('ContentNodeSelectorPanelComponent', () => { }); it('should the user query get updated when the user types in the search input', fakeAsync(() => { - const updateSpy = spyOn(searchQueryBuilderService, 'update'); typeToSearchBox('search-term'); tick(debounceSearch); fixture.detectChanges(); - expect(updateSpy).toHaveBeenCalled(); + expect(searchSpy).toHaveBeenCalled(); expect(searchQueryBuilderService.userQuery).toEqual('(search-term*)'); expect(component.searchTerm).toEqual('search-term'); })); + it('should set the query builder search mode to formula when the user types in the search input', fakeAsync(() => { + typeToSearchBox('search-term'); + + tick(debounceSearch); + fixture.detectChanges(); + + expect(searchQueryBuilderService.searchMode).toEqual('formula'); + })); + + it('should add the wildcard suffix to the user query when wildcards are enabled', fakeAsync(() => { + spyOnProperty(searchQueryBuilderService, 'wildcardsEnabled', 'get').and.returnValue(true); + typeToSearchBox('search-term'); + + tick(debounceSearch); + fixture.detectChanges(); + + expect(searchQueryBuilderService.userQuery).toEqual('(search-term*)'); + })); + + it('should NOT add the wildcard suffix to the user query when wildcards are disabled', fakeAsync(() => { + spyOnProperty(searchQueryBuilderService, 'wildcardsEnabled', 'get').and.returnValue(false); + typeToSearchBox('search-term'); + + tick(debounceSearch); + fixture.detectChanges(); + + expect(searchQueryBuilderService.userQuery).toEqual('(search-term)'); + })); + it('should perform a search when the search request gets updated and it is defined', fakeAsync(() => { typeToSearchBox('search-term'); tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); + expect(searchSpy).toHaveBeenCalledWith(false); })); - it('should NOT perform a search and clear the results when the search request gets updated and it is NOT defined', async () => { + it('should NOT perform a search and clear the results when the search input is empty', fakeAsync(() => { spyOn(component, 'clearSearch'); - searchQueryBuilderService.userQuery = ''; - searchQueryBuilderService.update(); + typeToSearchBox(''); + tick(debounceSearch); fixture.detectChanges(); - await fixture.whenStable(); expect(searchSpy).not.toHaveBeenCalled(); expect(component.clearSearch).toHaveBeenCalled(); - }); + })); it('should reset the search term when clicking the clear icon', async () => { component.searchTerm = 'search-term'; searchQueryBuilderService.userQuery = 'search-term'; - spyOn(component, 'clearSearch'); + spyOn(component, 'clear').and.callThrough(); fixture.detectChanges(); const clearIcon = getSearchIcon('clear'); @@ -207,7 +233,7 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(searchQueryBuilderService.userQuery).toEqual(''); expect(component.searchTerm).toEqual(''); - expect(component.clearSearch).toHaveBeenCalled(); + expect(component.clear).toHaveBeenCalled(); }); it('should load the results by calling the search api on search change', fakeAsync(() => { @@ -216,22 +242,21 @@ describe('ContentNodeSelectorPanelComponent', () => { tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); + expect(searchSpy).toHaveBeenCalledWith(false); })); it('should the query include the show files filterQuery', fakeAsync(() => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); component.showFilesInResult = true; typeToSearchBox('search-term'); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries.push({ - query: `TYPE:'cm:folder' OR TYPE:'cm:content'` - }); + const expectedRequest = `TYPE:'cm:folder' OR TYPE:'cm:content'`; tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); })); it('should reset the currently chosen node in case of starting a new search', fakeAsync(() => { @@ -253,6 +278,7 @@ describe('ContentNodeSelectorPanelComponent', () => { }); it('should perform a search when selecting a site with the correct query', fakeAsync(() => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); typeToSearchBox('search-term'); tick(debounceSearch); @@ -261,14 +287,15 @@ describe('ContentNodeSelectorPanelComponent', () => { component.siteChanged({ entry: { guid: 'namek' } } as SiteEntry); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/namek'` }]; + const expectedRequest = `ANCESTOR:'workspace://SpacesStore/namek'`; expect(searchSpy.calls.count()).toBe(2); - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); })); it('should create the query with the right parameters on changing the site selectBox value from a custom dropdown menu', fakeAsync(() => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); component.dropdownSiteList = { list: { entries: [{ entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }] } } as SitePaging; component.documentList.folderNode = { id: 'fakeNodeId', isFolder: true, path: {} } as Node; fixture.detectChanges(); @@ -281,17 +308,12 @@ describe('ContentNodeSelectorPanelComponent', () => { component.siteChanged({ entry: { guid: '-sites-' } } as SiteEntry); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries = [ - { - query: `ANCESTOR:'workspace://SpacesStore/-sites-' OR ANCESTOR:'workspace://SpacesStore/123456testId' OR ANCESTOR:'workspace://SpacesStore/09876543testId'` - } - ]; + const expectedRequest = `ANCESTOR:'workspace://SpacesStore/-sites-' OR ANCESTOR:'workspace://SpacesStore/123456testId' OR ANCESTOR:'workspace://SpacesStore/09876543testId'`; expect(searchSpy).toHaveBeenCalled(); expect(searchSpy.calls.count()).toBe(2); - expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); })); it('should get the corresponding node ids on search when a known alias is selected from dropdown', fakeAsync(() => { @@ -395,7 +417,7 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(component.clear).toHaveBeenCalled(); })); - it('should clear the search field, nodes and chosenNode when clicking on the X (clear) icon', async () => { + it('should reset the search term and user query when clearing the search', async () => { component.chosenNode = [entry]; component.nodePaging = { @@ -404,32 +426,31 @@ describe('ContentNodeSelectorPanelComponent', () => { } }; component.searchTerm = 'piccolo'; + searchQueryBuilderService.userQuery = 'piccolo'; component.showingSearchResults = true; component.clear(); expect(component.searchTerm).toBe(''); - expect(component.nodePaging).toEqual(null); - expect(component.chosenNode).toBeNull(); - expect(component.showingSearchResults).toBeFalsy(); + expect(searchQueryBuilderService.userQuery).toBe(''); + expect(searchSpy).toHaveBeenCalledWith(false); }); it('should the query restrict the search to the currentFolderId in case is defined', fakeAsync(() => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); component.currentFolderId = 'my-root-id'; component.restrictRootToCurrentFolderId = true; component.ngOnInit(); typeToSearchBox('search-term'); tick(debounceSearch); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/my-root-id'` }]; + const expectedRequest = `ANCESTOR:'workspace://SpacesStore/my-root-id'`; - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); })); it('should emit showingSearch event with true while searching', async () => { - searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); spyOn(customResourcesService, 'hasCorrespondingNodeIds').and.returnValue(true); const showingSearchSpy = spyOn(component.showingSearch, 'emit'); @@ -454,8 +475,7 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(showingSearchSpy).toHaveBeenCalledWith(false); })); - it('should emit showingResults event with false when clicking on the X (clear) icon', async () => { - const showingSearchSpy = spyOn(component.showingSearch, 'emit'); + it('should re-run the query with an empty user query when clicking on the X (clear) icon', async () => { component.chosenNode = [entry]; component.nodePaging = { @@ -464,17 +484,16 @@ describe('ContentNodeSelectorPanelComponent', () => { } }; component.searchTerm = 'piccolo'; - component.showingSearchResults = true; + searchQueryBuilderService.userQuery = 'piccolo'; component.clear(); - expect(component.showingSearchResults).toBe(false); - expect(showingSearchSpy).toHaveBeenCalledWith(false); + expect(component.searchTerm).toBe(''); + expect(searchQueryBuilderService.userQuery).toBe(''); + expect(searchSpy).toHaveBeenCalledWith(false); }); it('should emit showingResults event with false if search api fails', async () => { - searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); getCorrespondingNodeIdsSpy.and.throwError('Failed'); const showingSearchSpy = spyOn(component.showingSearch, 'emit'); await searchQueryBuilderService.execute(true, { query: { query: 'search' } }); @@ -488,15 +507,16 @@ describe('ContentNodeSelectorPanelComponent', () => { }); it('should the query restrict the search to the site and not to the currentFolderId in case is changed', async () => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); searchQueryBuilderService.userQuery = 'search-term*'; component.currentFolderId = 'my-root-id'; component.restrictRootToCurrentFolderId = true; component.siteChanged({ entry: { guid: 'my-site-id' } } as SiteEntry); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/my-site-id'` }]; + const expectedRequest = `ANCESTOR:'workspace://SpacesStore/my-site-id'`; - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); }); it('should restrict the breadcrumb to the currentFolderId in case restrictedRoot is true', async () => { @@ -563,10 +583,12 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(searchSpy.calls.count()).toBe(2); - component.clear(); + typeToSearchBox(''); + tick(debounceSearch); expect(component.searchTerm).toBe(''); expect(component.folderIdToShow).toBe('namek'); + flush(); })); it('should show the current folder content instead of search results if search was not performed', async () => { @@ -638,25 +660,23 @@ describe('ContentNodeSelectorPanelComponent', () => { fixture.detectChanges(); const documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); expect(documentList).not.toBeNull(); - expect(component.hasValidQuery).toEqual(true); expect(documentList.componentInstance.currentFolderId).toBeNull(); done(); }, 300); }); - it('should not show the result list when results are returned but there is no search term typed', (done) => { - searchQueryBuilderService.userQuery = ''; - searchQueryBuilderService.update(); + it('should not run a search nor show the result list when there is no search term typed', fakeAsync(() => { + spyOn(component, 'clearSearch').and.callThrough(); - setTimeout(() => { - triggerSearchResults(fakeResultSetPaging); - fixture.detectChanges(); + typeToSearchBox(''); + tick(debounceSearch); + fixture.detectChanges(); - expect(component.hasValidQuery).toEqual(false); - expect(component.showingSearchResults).toEqual(false); - done(); - }, 300); - }); + expect(searchSpy).not.toHaveBeenCalled(); + expect(component.clearSearch).toHaveBeenCalled(); + expect(component.showingSearchResults).toEqual(false); + flush(); + })); it('should highlight the results when search was performed in the next timeframe', (done) => { typeToSearchBox('My'); @@ -673,7 +693,7 @@ describe('ContentNodeSelectorPanelComponent', () => { }, 300); }); - it('should show the default text instead of result list if search was cleared', (done) => { + it('should reset the search term and re-run an empty query when the clear button is clicked', (done) => { typeToSearchBox(); setTimeout(() => { @@ -683,12 +703,13 @@ describe('ContentNodeSelectorPanelComponent', () => { fixture.whenStable().then(() => { const clearButton = getSearchIcon('clear'); expect(clearButton).not.toBeNull(); + searchSpy.calls.reset(); clearButton.triggerEventHandler('click', {}); fixture.detectChanges(); - const documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); - expect(documentList).not.toBeNull(); - expect(documentList.componentInstance.currentFolderId).toBe('cat-girl-nuku-nuku'); + expect(component.searchTerm).toBe(''); + expect(searchQueryBuilderService.userQuery).toBe(''); + expect(searchSpy).toHaveBeenCalledWith(false); done(); }); }, 300); @@ -709,20 +730,20 @@ describe('ContentNodeSelectorPanelComponent', () => { flush(); })); - it('should set the folderIdToShow to the default "currentFolderId" if siteId is undefined', (done) => { + it('should reset the folder to the selected site and fall back to currentFolderId when site is undefined', () => { component.siteChanged({ entry: { guid: 'Kame-Sennin Muten Roshi' } } as SiteEntry); + component.resetFolderToShow(); fixture.detectChanges(); let documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); expect(documentList.componentInstance.currentFolderId).toBe('Kame-Sennin Muten Roshi'); component.siteChanged({ entry: { guid: undefined } } as SiteEntry); + component.resetFolderToShow(); fixture.detectChanges(); documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); expect(documentList.componentInstance.currentFolderId).toBe('cat-girl-nuku-nuku'); - - done(); }); describe('Pagination "Load more" button', () => { @@ -752,7 +773,7 @@ describe('ContentNodeSelectorPanelComponent', () => { }); it('should set its loading state to true to perform a new search', async () => { - component.prepareDialogForNewSearch(mockSearchRequest); + component.prepareDialogForNewSearch(); fixture.detectChanges(); await fixture.whenStable(); @@ -764,13 +785,14 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(paginationLoading).not.toBeNull(); }); - it('Should infinite pagination target be null when we use it for search ', fakeAsync(() => { + it('Should keep the document list as infinite pagination target while searching', fakeAsync(() => { component.showingSearchResults = true; typeToSearchBox('shenron'); tick(debounceSearch); fixture.detectChanges(); - expect(component.target).toBeNull(); + expect(component.target).toEqual(component.documentList); + flush(); })); it('Should infinite pagination target be present when search finish', () => { diff --git a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.spec.ts b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.spec.ts index e43ceb0da0..be8db30ea9 100644 --- a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.spec.ts +++ b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.spec.ts @@ -270,7 +270,6 @@ describe('ContentNodeSelectorPanelComponent', () => { it('should not show the breadcrumb if search was performed as last action', async () => { searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); triggerSearchResults(fakeResultSetPaging); fixture.detectChanges(); @@ -291,7 +290,6 @@ describe('ContentNodeSelectorPanelComponent', () => { it('should show the breadcrumb in search results for a valid node selection', async () => { searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); triggerSearchResults(fakeResultSetPaging); const chosenNode = new Node({ path: { elements: [{ name: 'one' }] } }); @@ -306,7 +304,6 @@ describe('ContentNodeSelectorPanelComponent', () => { it('should show the breadcrumb in search results even for an invalid node selection', async () => { component.isSelectionValid = (node: Node) => node.isFile; searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); triggerSearchResults(fakeResultSetPaging); const chosenNode = new Node({ path: { elements: [{ name: 'fake-path' }] }, isFile: false, isFolder: true }); diff --git a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.ts b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.ts index b9e27e3015..007e06f7f2 100644 --- a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.ts +++ b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.ts @@ -33,7 +33,7 @@ import { } from '@alfresco/adf-core'; import { FileUploadCompleteEvent, FileUploadDeleteEvent, NodesApiService, SitesService, UploadService } from '../../common'; import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; -import { Node, NodeEntry, NodePaging, Pagination, RequestScope, SearchRequest, SiteEntry, SitePaging } from '@alfresco/js-api'; +import { Node, NodeEntry, NodePaging, Pagination, RequestScope, SiteEntry, SitePaging } from '@alfresco/js-api'; import { DocumentListComponent } from '../../document-list/components/document-list.component'; import { RowFilter } from '../../document-list/data/row-filter.model'; import { ImageResolver } from '../../document-list/data/image-resolver.model'; @@ -292,7 +292,6 @@ export class ContentNodeSelectorPanelComponent implements OnInit { folderIdToShow: string | null = null; breadcrumbFolderTitle: string | null = null; startSiteGuid: string | null = null; - hasValidQuery: boolean = false; showHeader = ShowHeaderMode.Never; @ViewChild(InfinitePaginationComponent, { static: true }) @@ -334,26 +333,16 @@ export class ContentNodeSelectorPanelComponent implements OnInit { .pipe(debounceTime(this.debounceSearch), takeUntilDestroyed(this.destroyRef)) .subscribe((searchValue: string) => { this.searchTerm = searchValue; - this.queryBuilderService.userQuery = searchValue.length > 0 ? `${searchValue}*` : searchValue; - this.queryBuilderService.update(); + if (this.searchTerm) { + this.executeSearch(searchValue); + } else { + this.resetFolderToShow(); + this.clearSearch(); + } }); - this.queryBuilderService.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((searchRequest) => { - if (searchRequest) { - this.hasValidQuery = true; - this.prepareDialogForNewSearch(searchRequest); - this.queryBuilderService.execute(false, searchRequest); - } else { - this.hasValidQuery = false; - this.resetFolderToShow(); - this.clearSearch(); - } - }); - this.queryBuilderService.executed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((results: NodePaging) => { - if (this.hasValidQuery) { - this.showSearchResults(results); - } + this.showSearchResults(results); }); this.userPreferencesService @@ -451,7 +440,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit { this.siteId = chosenSite.entry.guid; this.setTitleIfCustomSite(chosenSite); this.siteChange.emit(chosenSite.entry.title); - this.queryBuilderService.update(); + this.executeSearch(this.searchTerm); } /** @@ -473,14 +462,8 @@ export class ContentNodeSelectorPanelComponent implements OnInit { /** * Prepares the dialog for a new search - * - * @param searchRequest request options */ - prepareDialogForNewSearch(searchRequest: SearchRequest): void { - this.target = searchRequest ? null : this.documentList; - if (this.target) { - this.infinitePaginationComponent.reset(); - } + prepareDialogForNewSearch(): void { this.folderIdToShow = null; this.preselectedNodes = []; this.loadingSearchResults = true; @@ -494,7 +477,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit { clear(): void { this.searchTerm = ''; this.queryBuilderService.userQuery = ''; - this.queryBuilderService.update(); + this.executeSearch(this.searchTerm); } /** @@ -619,7 +602,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit { this.queryBuilderService.paging.skipCount = pagination.skipCount; if (this.searchTerm.length > 0) { - this.queryBuilderService.update(); + this.executeSearch(this.searchTerm); } } @@ -696,4 +679,12 @@ export class ContentNodeSelectorPanelComponent implements OnInit { skipCount: this.DEFAULT_PAGINATION.skipCount }; } + + private executeSearch(searchValue: string): void { + this.prepareDialogForNewSearch(); + this.queryBuilderService.searchMode = 'formula'; + const wildcardSuffix = this.queryBuilderService.wildcardsEnabled ? '*' : ''; + this.queryBuilderService.userQuery = searchValue.length > 0 ? `(${searchValue}${wildcardSuffix})` : searchValue; + this.queryBuilderService.execute(false); + } } diff --git a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts index 6e13882fad..008e5af98c 100644 --- a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts +++ b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts @@ -23,6 +23,8 @@ import { SimpleChange } from '@angular/core'; import { SearchHeaderQueryBuilderService } from './../../../search/services/search-header-query-builder.service'; import { FilterHeaderComponent } from './filter-header.component'; import { provideRouter } from '@angular/router'; +import { SearchCategory } from '@alfresco/adf-content-services'; +import { NodePaging } from '@alfresco/js-api'; describe('FilterHeaderComponent', () => { let fixture: ComponentFixture; @@ -150,7 +152,7 @@ describe('FilterHeaderComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(Object.keys(queryBuilder.filterRawParams).length).toBe(0); + expect(queryBuilder.filterRawParams['name']).toBeUndefined(); component.value = { name: 'pinocchio' }; const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); @@ -158,11 +160,41 @@ describe('FilterHeaderComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(Object.keys(queryBuilder.filterRawParams).length).toBe(1); expect(queryBuilder.filterRawParams['name']).toBe('pinocchio'); expect(queryBuilder.queryFragments['name']).toBe('pinocchio'); }); + it('should build a wildcard field query fragment for the queryName filter when wildcards are enabled', async () => { + spyOn(queryBuilder, 'setCurrentRootFolderId'); + spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false); + spyOnProperty(queryBuilder, 'wildcardsEnabled', 'get').and.returnValue(true); + queryBuilder.categories = [{ id: 'queryName', component: { settings: { field: 'cm:name' } } } as SearchCategory]; + + component.value = { queryName: 'pinocchio' }; + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(queryBuilder.filterRawParams['queryName']).toBe('pinocchio'); + expect(queryBuilder.queryFragments['queryName']).toBe(`cm:name:'*pinocchio*'`); + }); + + it('should build a non-wildcard field query fragment for the queryName filter when wildcards are disabled', async () => { + spyOn(queryBuilder, 'setCurrentRootFolderId'); + spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false); + spyOnProperty(queryBuilder, 'wildcardsEnabled', 'get').and.returnValue(false); + queryBuilder.categories = [{ id: 'queryName', component: { settings: { field: 'cm:name' } } } as SearchCategory]; + + component.value = { queryName: 'pinocchio' }; + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(queryBuilder.queryFragments['queryName']).toBe(`cm:name:'pinocchio'`); + }); + it('should emit filterSelection when a filter is changed', (done) => { spyOn(queryBuilder, 'getActiveFilters').and.returnValue([{ key: 'name', value: 'pinocchio' }]); @@ -193,7 +225,7 @@ describe('FilterHeaderComponent', () => { it('should emit searchResultsReady when search query builder executes', (done) => { fixture.detectChanges(); // Initialize component (triggers ngOnInit) - const mockNodePaging: any = { list: { entries: [] } }; + const mockNodePaging: NodePaging = { list: { entries: [] } }; component.searchResultsReady.subscribe((nodePaging) => { expect(nodePaging).toBe(mockNodePaging); diff --git a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts index d1ede34a2d..7515018fc6 100644 --- a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts +++ b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts @@ -115,9 +115,16 @@ export class FilterHeaderComponent implements OnInit, OnChanges { const operator = this.searchFilterQueryBuilder.getOperatorForFilterId(key) || 'OR'; this.searchFilterQueryBuilder.filterRawParams[key] = this.value[key]; - this.searchFilterQueryBuilder.queryFragments[key] = Array.isArray(this.value[key]) - ? this.value[key].join(` ${operator} `) - : this.value[key]; + if (key === 'queryName') { + const filterConfig = this.searchFilterQueryBuilder.categories.find((category) => category.id === key); + const wildcardSuffix = this.searchFilterQueryBuilder.wildcardsEnabled ? '*' : ''; + this.searchFilterQueryBuilder.queryFragments[key] = + `${filterConfig.component.settings.field}:'${wildcardSuffix}${this.value[key]}${wildcardSuffix}'`; + } else { + this.searchFilterQueryBuilder.queryFragments[key] = Array.isArray(this.value[key]) + ? this.value[key].join(` ${operator} `) + : this.value[key]; + } }); } this.searchFilterQueryBuilder.setCurrentRootFolderId(currentFolderId); diff --git a/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.spec.ts b/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.spec.ts index 9330c57f48..09c0f8926b 100644 --- a/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.spec.ts @@ -44,7 +44,7 @@ describe('SearchCheckListComponent', () => { queryFragments: {}, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy() + execute: jasmine.createSpy('execute') } as any; }); @@ -134,7 +134,7 @@ describe('SearchCheckListComponent', () => { component.reset(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); @@ -226,7 +226,7 @@ describe('SearchCheckListComponent', () => { fixture.detectChanges(); expect(component.context.queryFragments[component.id]).toBe(''); - expect(component.context.update).not.toHaveBeenCalled(); + expect(component.context.execute).not.toHaveBeenCalled(); }); it('should populate filter state when populate filters event has been observed', () => { diff --git a/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts b/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts index 5551aa2078..d8bd4ca28b 100644 --- a/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts +++ b/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts @@ -107,7 +107,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { this.clearOptions(); if (this.id && this.context && this.enableChangeUpdate) { this.updateDisplayValue(); - this.context.update(); + this.context.execute(); } } @@ -128,7 +128,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { if (this.id && this.context) { this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -178,7 +178,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { this.context.queryFragments[this.id] = query; this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts index bc2af8a734..e13c2230ca 100644 --- a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts @@ -309,6 +309,13 @@ describe('SearchChipAutocompleteInputComponent', () => { expect(inputChangedSpy).toHaveBeenCalledOnceWith('test-value'); }); + it('should not emit input value when input is empty', async () => { + const inputChangedSpy = spyOn(component.inputChanged, 'emit'); + enterNewInputValue(''); + await fixture.whenStable(); + expect(inputChangedSpy).not.toHaveBeenCalled(); + }); + describe('isOptionSelected', () => { beforeEach(() => { component.autocompleteOptions = [{ value: 'option1' }, { value: 'option2' }]; diff --git a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts index cb811d0655..ea26560a50 100644 --- a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts @@ -108,8 +108,10 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnChanges { takeUntilDestroyed(this.destroyRef) ) .subscribe((value: string) => { - this.filteredOptions = this.filter(this.autocompleteOptions, value); - this.inputChanged.emit(value); + if (value) { + this.filteredOptions = this.filter(this.autocompleteOptions, value); + this.inputChanged.emit(value); + } }); this.onReset$?.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.reset()); this.selectedOptions = this.preselectedOptions ?? []; diff --git a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.spec.ts b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.spec.ts index 4110fbf818..c1878bd662 100644 --- a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.spec.ts @@ -79,7 +79,7 @@ describe('SearchDateRangeTabbedComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { hideDefaultAction: false, @@ -210,7 +210,7 @@ describe('SearchDateRangeTabbedComponent', () => { expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(anyMockDate); }); - it('should trigger context.update() when values are submitted', () => { + it('should trigger context.execute() when values are submitted', () => { component.onDateRangedValueChanged(betweenMockData, 'createdDate'); component.onDateRangedValueChanged(inLastMockData, 'modifiedDate'); fixture.detectChanges(); @@ -221,7 +221,7 @@ describe('SearchDateRangeTabbedComponent', () => { `createdDate:['${formatISO(startOfDay(betweenMockData.betweenStartDate))}' TO '${formatISO(endOfDay(betweenMockData.betweenEndDate))}']` + ` AND modifiedDate:['${formatISO(startOfDay(inLastStartDate))}' TO '${formatISO(endOfToday())}']`; expect(component.context.queryFragments['dateRange']).toEqual(query); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should clear values and search filter when widget is reset', (done) => { @@ -231,7 +231,7 @@ describe('SearchDateRangeTabbedComponent', () => { expect(component.combinedDisplayValue).toBe(''); expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.context.queryFragments['dateRange']).toEqual(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); component.fields.forEach((field) => expect(component.context.filterRawParams[field]).toBeUndefined()); done(); }); diff --git a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.ts b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.ts index e4c6ab2200..e40f3071e1 100644 --- a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.ts +++ b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.ts @@ -138,7 +138,7 @@ export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit { this.context.queryFragments[this.id] = this.combinedQuery; this.displayValue$.next(this.combinedDisplayValue); if (this.id && this.context && updateContext) { - this.context.update(); + this.context.execute(); } } onDateRangedValueChanged(value: Partial, field: string) { diff --git a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts index 75ef62ec92..b495fba6f2 100644 --- a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts @@ -46,7 +46,7 @@ describe('SearchDatetimeRangeComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { field: 'cm:created' }; }); @@ -128,7 +128,7 @@ describe('SearchDatetimeRangeComponent', () => { component.reset(); expect(component.context.queryFragments.createdDatetimeRange).toEqual(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should update the query in UTC format when values change', async () => { @@ -150,7 +150,7 @@ describe('SearchDatetimeRangeComponent', () => { expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); expect(component.context.filterRawParams[component.id]).toEqual({ start: expectedFromDate, end: expectedToDate }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should be able to update the query in UTC format from a GMT format', async () => { @@ -175,7 +175,7 @@ describe('SearchDatetimeRangeComponent', () => { expect(startDate).toContain('2021-02-24'); expect(endDate).toContain('2021-02-28'); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); // Verify the query structure is correct without hardcoding exact timezone values const query = component.context.queryFragments[component.id]; diff --git a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts index d9110b2325..e3a0c9a53a 100644 --- a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts +++ b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts @@ -171,7 +171,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { filterParam.end = end; this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -242,7 +242,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { private updateQuery() { if (this.id && this.context) { this.updateDisplayValue(); - this.context.update(); + this.context.execute(); } } diff --git a/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.spec.ts b/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.spec.ts index ea9d11d69f..9271bac47c 100644 --- a/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.spec.ts @@ -44,7 +44,7 @@ describe('SearchFacetFieldComponent', () => { }); it('should update bucket model and query builder on facet toggle', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'addUserFacetBucket').and.callThrough(); const event: any = { checked: true }; @@ -57,12 +57,12 @@ describe('SearchFacetFieldComponent', () => { expect(bucket.checked).toBeTruthy(); expect(queryBuilder.addUserFacetBucket).toHaveBeenCalledWith(facetField.field, bucket); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); }); it('should update bucket model and query builder on facet un-toggle', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); const event: any = { checked: false }; @@ -75,12 +75,12 @@ describe('SearchFacetFieldComponent', () => { component.onToggleBucket(event, facetField, bucket); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(facetField.field, bucket); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); }); it('should unselect facet query and update builder', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); const event: any = { checked: false }; @@ -94,17 +94,17 @@ describe('SearchFacetFieldComponent', () => { expect(query.checked).toEqual(false); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(facetField.field, query); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); }); it('should update query builder only when has bucket to unselect', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const field: FacetField = { field: 'f1', label: 'f1' }; component.onToggleBucket({ checked: true } as any, field, null); - expect(queryBuilder.update).not.toHaveBeenCalled(); + expect(queryBuilder.execute).not.toHaveBeenCalled(); }); it('should allow to to reset selected buckets', () => { @@ -166,7 +166,7 @@ describe('SearchFacetFieldComponent', () => { }); it('should update query builder upon resetting buckets', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const buckets: FacetFieldBucket[] = [ { label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' }, @@ -183,6 +183,6 @@ describe('SearchFacetFieldComponent', () => { fixture.detectChanges(); component.resetSelectedBuckets(field); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); }); diff --git a/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.ts b/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.ts index 20f1accdd3..95d90b0c1b 100644 --- a/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.ts +++ b/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.ts @@ -69,7 +69,7 @@ export class SearchFacetFieldComponent implements FacetWidget { this.searchFacetFiltersService.updateSelectedBuckets(); if (this.canUpdateOnChange) { this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } } } @@ -81,7 +81,7 @@ export class SearchFacetFieldComponent implements FacetWidget { this.searchFacetFiltersService.updateSelectedBuckets(); if (this.canUpdateOnChange) { this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } } } @@ -101,7 +101,7 @@ export class SearchFacetFieldComponent implements FacetWidget { } this.searchFacetFiltersService.updateSelectedBuckets(); if (this.canUpdateOnChange) { - this.queryBuilder.update(); + this.queryBuilder.execute(); } } } @@ -125,11 +125,11 @@ export class SearchFacetFieldComponent implements FacetWidget { reset(): void { this.resetSelectedBuckets(this.field); this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } submitValues(): void { this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } } diff --git a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts index 749ad5fa1d..082f5ab90a 100644 --- a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts @@ -47,7 +47,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { field: 'test', @@ -102,7 +102,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => { clearBtn.click(); expect(component.context.queryFragments[component.id]).toBe(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.selectedOptions).toEqual([]); expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.context.filterRawParams[component.id]).toBeUndefined(); @@ -116,7 +116,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => { applyBtn.click(); fixture.detectChanges(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('test:"option2" OR test:"option1"'); expect(component.context.filterRawParams[component.id]).toEqual([{ value: 'option2' }, { value: 'option1' }]); diff --git a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts index ce02115a9a..e97c291ad9 100644 --- a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts @@ -111,7 +111,6 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI this.selectedOptions = selectedOptions; if (this.enableChangeUpdate) { this.updateQuery(); - this.context.update(); } } @@ -160,7 +159,7 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI } this.context.queryFragments[this.id] = queryFragments.join(' OR '); if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.spec.ts index 5c800df6d1..995a0bc130 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.spec.ts @@ -33,7 +33,7 @@ describe('SearchFacetTabbedContentComponent', () => { let queryBuilder: SearchQueryBuilderService; let searchFacetService: SearchFacetFiltersService; let loader: HarnessLoader; - let queryBuilderUpdateSpy: jasmine.Spy; + let queryBuilderExecuteSpy: jasmine.Spy; beforeEach(() => { TestBed.configureTestingModule({ @@ -44,7 +44,7 @@ describe('SearchFacetTabbedContentComponent', () => { component = fixture.componentInstance; queryBuilder = TestBed.inject(SearchQueryBuilderService); searchFacetService = TestBed.inject(SearchFacetFiltersService); - queryBuilderUpdateSpy = spyOn(queryBuilder, 'update').and.stub(); + queryBuilderExecuteSpy = spyOn(queryBuilder, 'execute').and.stub(); const facet1: FacetField = { type: 'field', label: 'field', field: 'field', buckets: new SearchFilterList() }; const facet2: FacetField = { type: 'field', label: 'field2', field: 'field2', buckets: new SearchFilterList() }; @@ -185,7 +185,7 @@ describe('SearchFacetTabbedContentComponent', () => { spyOn(searchFacetService, 'updateSelectedBuckets').and.callThrough(); component.submitValues(); expect(component.submitValues).toHaveBeenCalled(); - expect(queryBuilderUpdateSpy).toHaveBeenCalled(); + expect(queryBuilderExecuteSpy).toHaveBeenCalled(); expect(component.updateDisplayValue).toHaveBeenCalled(); expect(searchFacetService.updateSelectedBuckets).toHaveBeenCalled(); }); @@ -193,12 +193,12 @@ describe('SearchFacetTabbedContentComponent', () => { it('should update search query and display value on reset', () => { spyOn(component, 'updateDisplayValue').and.callThrough(); component.reset(); - expect(queryBuilderUpdateSpy).toHaveBeenCalled(); + expect(queryBuilderExecuteSpy).toHaveBeenCalled(); expect(component.updateDisplayValue).toHaveBeenCalled(); }); - it('should not call queryBuilder.update on options change', () => { + it('should not call queryBuilder.execute on options change', () => { component.onOptionsChange([{ value: 'test' }], 'field'); - expect(queryBuilderUpdateSpy).not.toHaveBeenCalled(); + expect(queryBuilderExecuteSpy).not.toHaveBeenCalled(); }); }); diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.ts index 6e92e01226..3e9d0bd528 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.ts @@ -111,14 +111,14 @@ export class SearchFacetTabbedContentComponent implements OnInit, OnChanges, Fac this.resetSubject$.next(); this.updateUserFacetBuckets(); this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } submitValues() { this.updateUserFacetBuckets(); this.searchFacetFiltersService.updateSelectedBuckets(); this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } optionComparator(option1: AutocompleteOption, option2: AutocompleteOption): boolean { diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip/search-facet-chip.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip/search-facet-chip.component.spec.ts index 20a8f80f91..127ba658f0 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip/search-facet-chip.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip/search-facet-chip.component.spec.ts @@ -41,7 +41,7 @@ describe('SearchFacetChipComponent', () => { fixture = TestBed.createComponent(SearchFacetChipComponent); component = fixture.componentInstance; queryBuilder = TestBed.inject(SearchQueryBuilderService); - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); component.field = { type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList() }; fixture.detectChanges(); @@ -55,7 +55,7 @@ describe('SearchFacetChipComponent', () => { const applyButton = await menu.getHarness(MatButtonHarness.with({ selector: '#apply-filter-button' })); await applyButton.click(); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); it('should update search query on cancel click', async () => { @@ -65,7 +65,7 @@ describe('SearchFacetChipComponent', () => { const cancelButton = await menu.getHarness(MatButtonHarness.with({ selector: '#cancel-filter-button' })); await cancelButton.click(); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); it('should display arrow down icon and not disable the chip when items are loaded', async () => { diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.spec.ts index c11e2fe14a..a8366eed8f 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.spec.ts @@ -223,7 +223,7 @@ describe('SearchFilterChipsComponent', () => { }); it('should update query builder upon resetting selected queries', async () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); const queryResponse = { @@ -247,7 +247,7 @@ describe('SearchFilterChipsComponent', () => { facetField.resetSelectedBuckets(queryResponse); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) { expect(entry.checked).toEqual(false); @@ -276,7 +276,7 @@ describe('SearchFilterChipsComponent', () => { }); it('should be update the search query when name changed', async () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); appConfigService.config.search = searchFilter; queryBuilder.resetToDefaults(); @@ -290,7 +290,7 @@ describe('SearchFilterChipsComponent', () => { const inputElement = fixture.debugElement.query(By.css('[data-automation-id="search-field-Name"] input')); inputElement.triggerEventHandler('change', { target: { value: '*' } }); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); queryBuilder.executed.next(mockSearchResult); @@ -404,7 +404,7 @@ describe('SearchFilterChipsComponent', () => { queryBuilder.executed.next(mockSearchResult); fixture.detectChanges(); - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const searchChip = fixture.debugElement.query(By.css(`[data-automation-id="search-filter-chip-Size facet queries"]`)); searchChip.triggerEventHandler('click', { stopPropagation: () => null }); @@ -441,7 +441,7 @@ describe('SearchFilterChipsComponent', () => { await filteredMenu[0].check(); expect(await filteredMenu[0].getLabelText()).toEqual('Extra Small (10239)'); - expect(queryBuilder.update).toHaveBeenCalledTimes(1); + expect(queryBuilder.execute).toHaveBeenCalledTimes(1); }); }); }); diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.spec.ts index b50258f57e..7e84809ccc 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.spec.ts @@ -45,7 +45,7 @@ describe('SearchWidgetChipComponent', () => { queryBuilder = TestBed.inject(SearchQueryBuilderService); fixture = TestBed.createComponent(SearchWidgetChipComponent); component = fixture.componentInstance; - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); component.category = simpleCategories[1]; fixture.detectChanges(); @@ -60,7 +60,7 @@ describe('SearchWidgetChipComponent', () => { const applyButton = fixture.debugElement.query(By.css('#apply-filter-button')); applyButton.triggerEventHandler('click', {}); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); it('should update search query on cancel click', async () => { @@ -69,7 +69,7 @@ describe('SearchWidgetChipComponent', () => { const applyButton = fixture.debugElement.query(By.css('#cancel-filter-button')); applyButton.triggerEventHandler('click', {}); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); it('should display arrow down icon', async () => { diff --git a/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts index ccbb0de7e1..93115480a2 100644 --- a/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts @@ -240,7 +240,7 @@ describe('SearchFilterComponent', () => { }); it('should update query builder upon resetting selected queries', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); const queryResponse = { @@ -258,7 +258,7 @@ describe('SearchFilterComponent', () => { facetField.resetSelectedBuckets(queryResponse); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) { expect(entry.checked).toEqual(false); @@ -320,7 +320,7 @@ describe('SearchFilterComponent', () => { }); it('should be update the search query when name changed', async () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); appConfigService.config.search = searchFilter; queryBuilder.resetToDefaults(); @@ -333,7 +333,7 @@ describe('SearchFilterComponent', () => { const inputElement = fixture.debugElement.query(By.css('[data-automation-id="expansion-panel-Name"] input')); inputElement.triggerEventHandler('change', { target: { value: '*' } }); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); queryBuilder.executed.next(mockSearchResult); fixture.detectChanges(); @@ -441,7 +441,7 @@ describe('SearchFilterComponent', () => { queryBuilder.executed.next(mockSearchResult); fixture.detectChanges(); - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const panel = await loader.getHarness( MatExpansionPanelHarness.with({ @@ -470,7 +470,7 @@ describe('SearchFilterComponent', () => { expect(labels).toEqual(stepOne); await checkboxes[0].check(); - expect(queryBuilder.update).toHaveBeenCalledTimes(1); + expect(queryBuilder.execute).toHaveBeenCalledTimes(1); }); it('should preserve the filter state if other fields edited', async () => { @@ -481,7 +481,7 @@ describe('SearchFilterComponent', () => { fixture.detectChanges(); queryBuilder.executed.next(mockSearchResult); fixture.detectChanges(); - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const inputElement = fixture.debugElement.query(By.css(`${panel1Selector} input`)); inputElement.nativeElement.value = 'my'; @@ -511,7 +511,7 @@ describe('SearchFilterComponent', () => { const checkedOption = await panel1.getHarness(MatCheckboxHarness.with({ checked: true })); expect(await checkedOption.getLabelText()).toBe('my1 (806)'); - expect(queryBuilder.update).toHaveBeenCalledTimes(2); + expect(queryBuilder.execute).toHaveBeenCalledTimes(2); }); it('should reset the query fragments when reset All is clicked', () => { diff --git a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts index 8e1eb62382..3b5e835bbb 100644 --- a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts @@ -38,7 +38,7 @@ describe('SearchLogicalFilterComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { field: 'field1,field2', allowUpdateOnChange: true, hideDefaultAction: false }; fixture.detectChanges(); @@ -136,7 +136,7 @@ describe('SearchLogicalFilterComponent', () => { spyOn(component.displayValue$, 'next'); component.reset(); expect(component.context.queryFragments[component.id]).toBe(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.getCurrentValue()).toEqual({ matchAll: '', matchAny: '', exclude: '', matchExact: '' }); expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); @@ -145,7 +145,7 @@ describe('SearchLogicalFilterComponent', () => { it('should form correct query from match all field', () => { enterNewPhrase(' test1 test2 ', 0); component.submitValues(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('((field1:"test1" AND field1:"test2") OR (field2:"test1" AND field2:"test2"))'); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); @@ -153,7 +153,7 @@ describe('SearchLogicalFilterComponent', () => { it('should form correct query from match any field', () => { enterNewPhrase(' test3 test4', 1); component.submitValues(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('((field1:"test3" OR field1:"test4") OR (field2:"test3" OR field2:"test4"))'); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); @@ -161,7 +161,7 @@ describe('SearchLogicalFilterComponent', () => { it('should form correct query from exclude field', () => { enterNewPhrase('test5 test6 ', 2); component.submitValues(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe( '((NOT field1:"test5" AND NOT field1:"test6") AND (NOT field2:"test5" AND NOT field2:"test6"))' ); @@ -171,7 +171,7 @@ describe('SearchLogicalFilterComponent', () => { it('should form correct query from match exact field and trim it', () => { enterNewPhrase(' test7 test8 ', 3); component.submitValues(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('((field1:"test7 test8") OR (field2:"test7 test8"))'); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); @@ -186,7 +186,7 @@ describe('SearchLogicalFilterComponent', () => { const subQuery2 = '((field1:"test2") OR (field2:"test2"))'; const subQuery3 = '((NOT field1:"test3") AND (NOT field2:"test3"))'; const subQuery4 = '((field1:"test4") OR (field2:"test4"))'; - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe(`${subQuery1} AND ${subQuery2} AND ${subQuery4} AND ${subQuery3}`); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); diff --git a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts index 4ebc1a5327..1784cfd8ea 100644 --- a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts +++ b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts @@ -128,7 +128,7 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit { }); this.context.queryFragments[this.id] = query; if (updateContext) { - this.context.update(); + this.context.execute(); } } else { this.reset(updateContext); @@ -154,7 +154,7 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit { this.clearSearchInputs(); this.context.filterRawParams[this.id] = this.searchCondition; if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.spec.ts b/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.spec.ts index e8f5a7118d..87b66e9e79 100644 --- a/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.spec.ts @@ -37,7 +37,7 @@ describe('SearchNumberRangeComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; }); @@ -64,7 +64,7 @@ describe('SearchNumberRangeComponent', () => { component.reset(); expect(component.context.queryFragments.contentSize).toEqual(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); @@ -82,7 +82,7 @@ describe('SearchNumberRangeComponent', () => { const expectedQuery = 'cm:content.size:[10 TO 20]'; expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.filterRawParams[component.id].from).toEqual('10'); expect(component.context.filterRawParams[component.id].to).toEqual('20'); }); diff --git a/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts b/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts index 10fbbada01..d89d9fada2 100644 --- a/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts +++ b/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts @@ -123,7 +123,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { filterParam.from = model.from; filterParam.to = model.to; if (updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -178,7 +178,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { this.context.filterRawParams[this.id] = undefined; this.updateDisplayValue(); if (this.enableChangeUpdate && updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -186,7 +186,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { reset(updateContext = true) { this.clear(); if (this.id && this.context && updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.spec.ts b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.spec.ts index 6fb303e06f..a5dfcbb9c3 100644 --- a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.spec.ts @@ -72,7 +72,7 @@ describe('SearchPropertiesComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; }); @@ -208,7 +208,7 @@ describe('SearchPropertiesComponent', () => { component.submitValues(); expect(component.displayValue$.next).not.toHaveBeenCalled(); - expect(component.context.update).not.toHaveBeenCalled(); + expect(component.context.execute).not.toHaveBeenCalled(); }); it('should not search when context is not set', () => { @@ -227,7 +227,7 @@ describe('SearchPropertiesComponent', () => { fileExtensions: undefined, fileSizeCondition: { fileSize: null, fileSizeOperator: FileSizeOperator.AT_LEAST, fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by at least KB by default when any size is typed', () => { @@ -246,7 +246,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by at most MB after selecting proper options', () => { @@ -271,7 +271,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.MB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by exactly GB after selecting proper options', () => { @@ -296,7 +296,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.GB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by single file type', () => { @@ -314,7 +314,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by multiple file types', () => { @@ -331,7 +331,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by file size and type', () => { @@ -351,7 +351,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); }); @@ -439,7 +439,7 @@ describe('SearchPropertiesComponent', () => { expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.filterRawParams[component.id]).toBeUndefined(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); }); @@ -484,7 +484,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.MB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); }); diff --git a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts index f76455b37c..88c520809f 100644 --- a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts +++ b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts @@ -206,7 +206,7 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear this.context.queryFragments[this.id] = ''; this.context.filterRawParams[this.id] = undefined; if (updateContext) { - this.context.update(); + this.context.execute(); } } this.reset$.next(); @@ -272,7 +272,7 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear this.displayValue$.next(displayedValue); this.context.queryFragments[this.id] = query; if (updateContext) { - this.context.update(); + this.context.execute(); } } diff --git a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.spec.ts b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.spec.ts index 283afbd9a9..fcd26dc7ae 100644 --- a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.spec.ts @@ -43,7 +43,7 @@ describe('SearchRadioComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { options: sizeOptions } as any; }); @@ -93,6 +93,15 @@ describe('SearchRadioComponent', () => { expect(component.context.filterRawParams[component.id]).toBe(sizeOptions[0].value); }); + it('should call context.execute when reset is called', async () => { + const group = await loader.getHarness(MatRadioGroupHarness); + await group.checkRadioButton({ selector: `[data-automation-id="search-radio-${sizeOptions[2].name}"]` }); + + component.reset(); + + expect(component.context.execute).toHaveBeenCalled(); + }); + it('should populate filter state when populate filters event has been observed', async () => { component.context.filterLoaded = new ReplaySubject(1); spyOn(component.context.filterLoaded, 'next').and.stub(); diff --git a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts index 6802fc8863..ce9d103e75 100644 --- a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts +++ b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts @@ -114,7 +114,7 @@ export class SearchRadioComponent implements SearchWidget, OnInit { this.setValue(this.value); this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } @@ -129,7 +129,6 @@ export class SearchRadioComponent implements SearchWidget, OnInit { this.context.filterRawParams[this.id] = newValue; if (this.enableChangeUpdate) { this.updateDisplayValue(); - this.context.update(); } } @@ -164,7 +163,7 @@ export class SearchRadioComponent implements SearchWidget, OnInit { this.setValue(initialValue); this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.spec.ts b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.spec.ts index 52c37a67ba..50a9df1ca9 100644 --- a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.spec.ts @@ -36,7 +36,7 @@ describe('SearchSliderComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { field: 'field1', @@ -64,7 +64,7 @@ describe('SearchSliderComponent', () => { component.onChangedHandler(); expect(component.context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 10]'); expect(component.context.filterRawParams[component.id]).toEqual(10); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); component.value = 20; component.onChangedHandler(); @@ -81,7 +81,7 @@ describe('SearchSliderComponent', () => { expect(component.value).toBe(10); expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.filterRawParams[component.id]).toBe(null); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should reset to 0 if min not provided', () => { @@ -93,7 +93,7 @@ describe('SearchSliderComponent', () => { expect(component.value).toBe(0); expect(component.context.queryFragments['slider']).toBe(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should populate filter state when populate filters event has been observed', async () => { diff --git a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts index f826fdb7c4..fc5e43117b 100644 --- a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts +++ b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts @@ -135,7 +135,7 @@ export class SearchSliderComponent implements SearchWidget, OnInit { this.context.queryFragments[this.id] = `${this.settings.field}:[0 TO ${value}]`; } if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-text/search-text.component.spec.ts b/lib/content-services/src/lib/search/components/search-text/search-text.component.spec.ts index 9bb277f9d8..a2a88c40c6 100644 --- a/lib/content-services/src/lib/search/components/search-text/search-text.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-text/search-text.component.spec.ts @@ -22,11 +22,13 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatInputHarness } from '@angular/material/input/testing'; import { MatButtonHarness } from '@angular/material/button/testing'; import { ReplaySubject } from 'rxjs'; +import { AppConfigService } from '@alfresco/adf-core'; describe('SearchTextComponent', () => { let loader: HarnessLoader; let fixture: ComponentFixture; let component: SearchTextComponent; + let appConfig: AppConfigService; beforeEach(() => { TestBed.configureTestingModule({ @@ -34,6 +36,7 @@ describe('SearchTextComponent', () => { }); fixture = TestBed.createComponent(SearchTextComponent); component = fixture.componentInstance; + appConfig = TestBed.inject(AppConfigService); component.id = 'text'; component.settings = { pattern: `cm:name:'(.*?)'`, @@ -46,7 +49,10 @@ describe('SearchTextComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute'), + get wildcardsEnabled(): boolean { + return appConfig.get('search-wildcards-enabled', true); + } } as any; loader = TestbedHarnessEnvironment.loader(fixture); @@ -67,7 +73,10 @@ describe('SearchTextComponent', () => { expect(component.value).toEqual(''); }); - it('should update query builder on change', () => { + it('should update query builder on change when change updates are enabled', () => { + component.settings.allowUpdateOnChange = true; + fixture.detectChanges(); + component.onChangedHandler({ target: { value: 'top-secret.doc' @@ -77,10 +86,27 @@ describe('SearchTextComponent', () => { expect(component.value).toBe('top-secret.doc'); expect(component.context.queryFragments[component.id]).toBe(`cm:name:'top-secret.doc'`); expect(component.context.filterRawParams[component.id]).toBe('top-secret.doc'); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); - it('should reset query builder', () => { + it('should not update query builder on change when change updates are disabled', () => { + fixture.detectChanges(); + + component.onChangedHandler({ + target: { + value: 'top-secret.doc' + } + }); + + expect(component.value).toBe('top-secret.doc'); + expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.execute).not.toHaveBeenCalled(); + }); + + it('should reset query builder when change updates are enabled', () => { + component.settings.allowUpdateOnChange = true; + fixture.detectChanges(); + component.onChangedHandler({ target: { value: 'top-secret.doc' @@ -125,13 +151,21 @@ describe('SearchTextComponent', () => { expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); - it('should update query with startValue on init, if provided', () => { + it('should set value from startValue on init, if provided', () => { component.startValue = 'mock-start-value'; fixture.detectChanges(); - expect(component.context.queryFragments[component.id]).toBe(`cm:name:'mock-start-value'`); expect(component.value).toBe('mock-start-value'); - expect(component.context.update).toHaveBeenCalled(); + }); + + it('should build the query fragment from the current value when submitted', () => { + component.startValue = 'mock-start-value'; + fixture.detectChanges(); + + component.submitValues(); + + expect(component.context.queryFragments[component.id]).toBe(`cm:name:'mock-start-value'`); + expect(component.context.execute).toHaveBeenCalled(); }); it('should parse value and set query context as blank, and not call query update, if no start value was provided', () => { @@ -141,7 +175,7 @@ describe('SearchTextComponent', () => { expect(component.context.queryFragments[component.id]).toBe(''); expect(component.value).toBe('secret.pdf'); - expect(component.context.update).not.toHaveBeenCalled(); + expect(component.context.execute).not.toHaveBeenCalled(); }); it('should populate filter state when populate filters event has been observed', async () => { @@ -157,4 +191,30 @@ describe('SearchTextComponent', () => { expect(component.context.filterRawParams[component.id]).toBe('secret.pdf'); expect(component.context.filterLoaded.next).toHaveBeenCalled(); }); + + it('should add the search prefix and suffix to the query fragment when wildcards are enabled', () => { + spyOn(appConfig, 'get').and.callFake((key: string, defaultValue?: any) => (key === 'search-wildcards-enabled' ? true : defaultValue)); + component.settings.searchPrefix = '*'; + component.settings.searchSuffix = '*'; + fixture.detectChanges(); + + component.onChangedHandler({ target: { value: 'secret' } }); + component.submitValues(); + + expect(component.context.wildcardsEnabled).toBeTrue(); + expect(component.context.queryFragments[component.id]).toBe(`cm:name:'*secret*'`); + }); + + it('should NOT add the search prefix and suffix to the query fragment when wildcards are disabled', () => { + spyOn(appConfig, 'get').and.callFake((key: string, defaultValue?: any) => (key === 'search-wildcards-enabled' ? false : defaultValue)); + component.settings.searchPrefix = '*'; + component.settings.searchSuffix = '*'; + fixture.detectChanges(); + + component.onChangedHandler({ target: { value: 'secret' } }); + component.submitValues(); + + expect(component.context.wildcardsEnabled).toBeFalse(); + expect(component.context.queryFragments[component.id]).toBe(`cm:name:'secret'`); + }); }); diff --git a/lib/content-services/src/lib/search/components/search-text/search-text.component.ts b/lib/content-services/src/lib/search/components/search-text/search-text.component.ts index af71f55034..f565196bbd 100644 --- a/lib/content-services/src/lib/search/components/search-text/search-text.component.ts +++ b/lib/content-services/src/lib/search/components/search-text/search-text.component.ts @@ -48,7 +48,7 @@ export class SearchTextComponent implements SearchWidget, OnInit { context: SearchQueryBuilderService; startValue: string; isActive = false; - enableChangeUpdate = true; + enableChangeUpdate = false; displayValue$ = new ReplaySubject(1); private readonly destroyRef = inject(DestroyRef); @@ -121,7 +121,7 @@ export class SearchTextComponent implements SearchWidget, OnInit { if (this.context?.queryFragments && this.settings?.field) { this.context.queryFragments[this.id] = value ? `${this.settings.field}:'${this.getSearchPrefix()}${value}${this.getSearchSuffix()}'` : ''; if (updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -141,14 +141,13 @@ export class SearchTextComponent implements SearchWidget, OnInit { setValue(value: string) { this.value = value; this.displayValue$.next(this.value); - this.submitValues(); } private getSearchPrefix(): string { - return this.settings.searchPrefix ? this.settings.searchPrefix : ''; + return this.settings.searchPrefix && this.context.wildcardsEnabled ? this.settings.searchPrefix : ''; } private getSearchSuffix(): string { - return this.settings.searchSuffix ? this.settings.searchSuffix : ''; + return this.settings.searchSuffix && this.context.wildcardsEnabled ? this.settings.searchSuffix : ''; } } diff --git a/lib/content-services/src/lib/search/services/base-query-builder.service.spec.ts b/lib/content-services/src/lib/search/services/base-query-builder.service.spec.ts index 550a7ea599..b01c31e736 100644 --- a/lib/content-services/src/lib/search/services/base-query-builder.service.spec.ts +++ b/lib/content-services/src/lib/search/services/base-query-builder.service.spec.ts @@ -69,6 +69,12 @@ describe('BaseQueryBuilderService', () => { { ...mockSearchConfig, id: 'config-3', name: 'Config 3', default: false } ]; + const configureAppConfig = (wildcards?: boolean): void => { + (appConfig.get as jasmine.Spy).and.callFake((key: string, defaultValue?: any) => + key === 'search-wildcards-enabled' ? (wildcards ?? defaultValue) : false + ); + }; + beforeEach(() => { TestBed.configureTestingModule({ providers: [ @@ -89,19 +95,218 @@ describe('BaseQueryBuilderService', () => { }); describe('userQuery', () => { - it('should set userQuery with parentheses', () => { + it('should set userQuery to raw value without wrapping', () => { service.userQuery = 'test query'; - expect(service.userQuery).toBe('(test query)'); + expect(service.userQuery).toBe('test query'); }); - it('should trim userQuery', () => { - service.userQuery = ' test query '; - expect(service.userQuery).toBe('(test query)'); + it('should store userQuery in filterRawParams', () => { + service.userQuery = 'test'; + expect(service.filterRawParams['userQuery']).toBe('test'); }); - it('should return empty string for null input', () => { - service.userQuery = null; + it('should set parsedQuery when userQuery is set in regular mode', () => { + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('((cm:name:"hello*"))'); + }); + + it('should set parsedQuery equal to userQuery in formula mode', () => { + service.searchMode = 'formula'; + service.userQuery = '(cm:name:"test*")'; + expect(service.parsedQuery).toBe('(cm:name:"test*")'); + }); + + it('should clear userQuery and its raw param when set to empty', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'something'; + service.userQuery = ''; + expect(service.userQuery).toBe(''); + expect(service.filterRawParams['userQuery']).toBe(''); + }); + }); + + describe('parsedQuery', () => { + it('should wrap a single term with field query and parentheses in regular mode', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toContain('cm:name:"hello*"'); + }); + + it('should join multiple terms with AND in regular mode when no operator present', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello world'; + expect(service.parsedQuery).toContain(' AND '); + }); + + it('should preserve AND/OR operators in regular mode when present in query', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello AND world'; + expect(service.parsedQuery).toBe('((cm:name:"hello*") AND (cm:name:"world*"))'); + }); + + it('should store parsedQuery in filterRawParams', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.filterRawParams['parsedQuery']).toBe(service.parsedQuery); + }); + + it('should not add wildcard suffix when wildcards are disabled', () => { + configureAppConfig(false); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('((cm:name:"hello"))'); + }); + + it('should build the exact single-term parsed query against the default cm:name field', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('((cm:name:"hello*"))'); + }); + + it('should join each multi-term word with AND wrapped in parentheses', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello world'; + expect(service.parsedQuery).toBe('((cm:name:"hello*") AND (cm:name:"world*"))'); + }); + + it('should keep explicit operators as separators between terms', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello OR world'; + expect(service.parsedQuery).toBe('((cm:name:"hello*") OR (cm:name:"world*"))'); + }); + + it('should parse each term against every configured app:fields entry', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.config = { id: 'test-config', categories: [], 'app:fields': ['cm:name', 'cm:title'] } as any; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('((cm:name:"hello*" OR cm:title:"hello*"))'); + }); + + it('should escape double quotes in the user query term', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'he"llo'; + expect(service.parsedQuery).toBe('((cm:name:"he\\"llo*"))'); + }); + + it('should escape backslashes in the user query term', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'he\\llo'; + expect(service.parsedQuery).toBe('((cm:name:"he\\\\llo*"))'); + }); + + it('should clear parsedQuery and its raw param when userQuery is set to empty', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + service.userQuery = ''; + + expect(service.parsedQuery).toBe(''); + expect(service.filterRawParams['parsedQuery']).toBe(''); + }); + }); + + describe('searchMode', () => { + it('should default to regular mode', () => { + expect(service.searchMode).toBe('regular'); + }); + + it('should store searchMode in filterRawParams', () => { + service.searchMode = 'formula'; + expect(service.filterRawParams['searchMode']).toBe('formula'); + }); + + it('should update filterRawParams when changed back to regular', () => { + service.searchMode = 'formula'; + service.searchMode = 'regular'; + expect(service.filterRawParams['searchMode']).toBe('regular'); + }); + + it('should recompute parsedQuery when switching search mode', () => { + configureAppConfig(true); + service.searchMode = 'formula'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('hello'); + + service.searchMode = 'regular'; + expect(service.parsedQuery).toBe('((cm:name:"hello*"))'); + }); + }); + + describe('wildcardsEnabled', () => { + it('should return true by default', () => { + configureAppConfig(); + expect(service.wildcardsEnabled).toBe(true); + }); + + it('should return false when config disables wildcards', () => { + configureAppConfig(false); + expect(service.wildcardsEnabled).toBe(false); + }); + }); + + describe('isOperator', () => { + it('should return true for AND', () => { + expect(service.isOperator('AND')).toBeTrue(); + }); + + it('should return true for OR', () => { + expect(service.isOperator('OR')).toBeTrue(); + }); + + it('should return true for AND with surrounding spaces', () => { + expect(service.isOperator(' AND ')).toBeTrue(); + }); + + it('should return false for regular word', () => { + expect(service.isOperator('hello')).toBeFalse(); + }); + + it('should return false for empty string', () => { + expect(service.isOperator('')).toBeFalse(); + }); + + it('should return false for null/undefined', () => { + expect(service.isOperator(null)).toBeFalse(); + expect(service.isOperator(undefined)).toBeFalse(); + }); + }); + + describe('selectedConfigurationId', () => { + it('should store selectedConfigurationId in filterRawParams when set', () => { + (appConfig.get as jasmine.Spy).and.returnValue(mockMultipleConfigs); + service.resetToDefaults(); + + service.selectedConfigurationId = 'config-2'; + expect(service.filterRawParams['selectedConfigurationId']).toBe('config-2'); + }); + }); + + describe('encodedQuery', () => { + it('should return encoded query after encodeQuery is called', () => { + service.userQuery = 'test'; + service.encodeQuery(); + expect(service.encodedQuery).toBeTruthy(); + }); + + it('should return a base64 encoded string of filterRawParams', () => { + service.userQuery = 'test'; + service.encodeQuery(); + const decoded = new TextDecoder().decode(Uint8Array.from(atob(service.encodedQuery), (char) => char.charCodeAt(0))); + const parsed = JSON.parse(decoded); + expect(parsed['userQuery']).toBe('test'); }); }); @@ -215,15 +420,27 @@ describe('BaseQueryBuilderService', () => { expect(service.buildQuery()).toBeNull(); }); - it('should build query with userQuery', () => { + it('should build query with parsedQuery in regular mode', () => { + configureAppConfig(true); + service.searchMode = 'regular'; service.userQuery = 'test'; const query = service.buildQuery(); expect(query).toBeTruthy(); - expect(query.query.query).toBe('(test)'); + expect(query.query.query).toContain('cm:name:"test*"'); + }); + + it('should build query using userQuery directly in formula mode', () => { + service.searchMode = 'formula'; + service.userQuery = '(cm:name:"test*")'; + const query = service.buildQuery(); + + expect(query).toBeTruthy(); + expect(query.query.query).toBe('(cm:name:"test*")'); }); it('should include scope in query when set', () => { + service.searchMode = 'formula'; service.userQuery = 'test'; service.setScope({ locations: 'nodes' }); const query = service.buildQuery(); @@ -233,6 +450,7 @@ describe('BaseQueryBuilderService', () => { it('should include default includes when none configured', () => { service.config = { id: 'test-config', categories: [] }; + service.searchMode = 'formula'; service.userQuery = 'test'; const query = service.buildQuery(); @@ -241,29 +459,25 @@ describe('BaseQueryBuilderService', () => { }); }); - describe('update', () => { - it('should emit updated event with built query', (done) => { + describe('getFinalQuery', () => { + it('should skip query fragments that are empty match-all objects', () => { + service.searchMode = 'formula'; service.userQuery = 'test'; + service.categories = [{ id: 'cat1', name: 'Cat1', enabled: true, expanded: false, component: { selector: 'test', settings: undefined } }]; + service.queryFragments['cat1'] = { matchAll: '', matchAny: '', matchExact: '', exclude: '' }; - service.updated.subscribe((query) => { - expect(query.query.query).toBe('(test)'); - done(); - }); - - service.update(); + const query = service.buildQuery(); + expect(query.query.query).toBe('test'); }); - it('should emit updated event with provided query body', (done) => { - const customQuery = { - query: { query: 'custom query', language: 'afts' } - }; + it('should include non-empty query fragments', () => { + service.searchMode = 'formula'; + service.userQuery = 'test'; + service.categories = [{ id: 'cat1', name: 'Cat1', enabled: true, expanded: false, component: { selector: 'test', settings: undefined } }]; + service.queryFragments['cat1'] = 'cm:name:"hello"'; - service.updated.subscribe((query) => { - expect(query.query.query).toBe('custom query'); - done(); - }); - - service.update(customQuery); + const query = service.buildQuery(); + expect(query.query.query).toContain('cm:name:"hello"'); }); }); @@ -450,79 +664,24 @@ describe('BaseQueryBuilderService', () => { expect(service.execute).toHaveBeenCalled(); }); - }); - describe('populateFilters and selectedConfiguration restoration', () => { - beforeEach(() => { - (appConfig.get as jasmine.Spy<(key: string, defaultValue?: T) => T>).and.returnValue(mockMultipleConfigs); - service.resetToDefaults(); - }); - - it('should restore selectedConfiguration from populateFilters', (done) => { + it('should not call execute when shouldExecute is false', async () => { spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + spyOn(service, 'execute'); - service.configUpdated.subscribe((config) => { - expect(config.name).toBe('Config 3'); - done(); - }); + service.updateSelectedConfiguration('config-2', true, false); - service.populateFilters.next({ selectedConfigurationId: 'config-3', someOtherFilter: 'value' }); + expect(service.execute).not.toHaveBeenCalled(); }); - it('should reset to default configuration when populateFilters has no selectedConfigurationId', (done) => { + it('should not reset search options when resetFilters is false', () => { spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve({ list: { entries: [] } } as ResultSetPaging)); - service.updateSelectedConfiguration('config-3'); + service.queryFragments['someFilter'] = 'some value'; + service.updateSelectedConfiguration('config-2', false, false); - setTimeout(() => { - service.configUpdated.subscribe((config) => { - expect(config.name).toBe('Config 1'); - done(); - }); - - service.populateFilters.next({ someOtherFilter: 'value' }); - }, 0); - }); - - it('should not change configuration when populateFilters is empty', () => { - spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); - const configUpdatedSpy = jasmine.createSpy('configUpdatedSpy'); - - service.configUpdated.subscribe(configUpdatedSpy); - configUpdatedSpy.calls.reset(); - - service.populateFilters.next({}); - - expect(configUpdatedSpy).not.toHaveBeenCalled(); - }); - - it('should not change configuration when same configuration is already selected', (done) => { - spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); - spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve({ list: { entries: [] } } as ResultSetPaging)); - - service.updateSelectedConfiguration('config-2'); - - setTimeout(() => { - const configUpdatedSpy = jasmine.createSpy('configUpdatedSpy'); - service.configUpdated.subscribe(configUpdatedSpy); - - service.populateFilters.next({ selectedConfigurationId: 'config-2' }); - - setTimeout(() => { - expect(configUpdatedSpy).not.toHaveBeenCalled(); - done(); - }, 0); - }, 0); - }); - - it('should update filterRawParams when restoring configuration from populateFilters', (done) => { - service.configUpdated.subscribe(() => { - expect(service.filterRawParams['selectedConfigurationId']).toBe('config-2'); - done(); - }); - - service.populateFilters.next({ selectedConfigurationId: 'config-2' }); + expect(service.queryFragments['someFilter']).toBe('some value'); }); }); @@ -577,5 +736,17 @@ describe('BaseQueryBuilderService', () => { expect(service.categories.length).toBe(1); expect(service.categories[0].id).toBe('cat1'); }); + + it('should reset userQuery when resetUserQuery is true (default)', () => { + service.userQuery = 'some query'; + service.resetToDefaults(false, true); + expect(service.userQuery).toBe(''); + }); + + it('should preserve userQuery when resetUserQuery is false', () => { + service.userQuery = 'some query'; + service.resetToDefaults(false, false); + expect(service.userQuery).toBe('some query'); + }); }); }); diff --git a/lib/content-services/src/lib/search/services/base-query-builder.service.ts b/lib/content-services/src/lib/search/services/base-query-builder.service.ts index 16ca722fd4..670839fe20 100644 --- a/lib/content-services/src/lib/search/services/base-query-builder.service.ts +++ b/lib/content-services/src/lib/search/services/base-query-builder.service.ts @@ -54,9 +54,6 @@ export abstract class BaseQueryBuilderService { /* Stream that emits the event each time when search filter finishes loading initial value */ filterLoaded = new Subject(); - /* Stream that emits the query before search whenever user search */ - updated = new Subject(); - /* Stream that emits the results whenever user search */ executed = new Subject(); @@ -85,11 +82,13 @@ export abstract class BaseQueryBuilderService { sorting: SearchSortingDefinition[] = []; sortingOptions: SearchSortingDefinition[] = []; - private encodedQuery: string; + private _encodedQuery: string; private scope: RequestScope; - private selectedConfigurationId: string; - private _userQuery = ''; + private _selectedConfigurationId: string; private _queryFragments: { [id: string]: string } = {}; + private _parsedQuery: string; + private _userQuery: string; + private _searchMode: 'regular' | 'formula'; private readonly selectedConfigurationKey = 'selectedConfigurationId'; private readonly queryFragmentsHandler: ProxyHandler<{ [key: string]: any }> = { @@ -111,13 +110,45 @@ export abstract class BaseQueryBuilderService { this.queryFragmentsUpdate.next(this._queryFragments); } + get encodedQuery(): string { + return this._encodedQuery; + } + + get wildcardsEnabled(): boolean { + return this.appConfig.get('search-wildcards-enabled', true); + } + get userQuery(): string { return this._userQuery; } set userQuery(value: string) { - value = (value || '').trim(); - this._userQuery = value ? `(${value})` : ''; + this._userQuery = value; + this.filterRawParams['userQuery'] = value; + this.setParsedQuery(); + } + + get parsedQuery(): string { + return this._parsedQuery; + } + + get searchMode(): 'regular' | 'formula' { + return this._searchMode; + } + + set searchMode(value: 'regular' | 'formula') { + this._searchMode = value; + this.filterRawParams['searchMode'] = value; + this.setParsedQuery(); + } + + get selectedConfigurationId(): string { + return this._selectedConfigurationId; + } + + set selectedConfigurationId(value: string) { + this._selectedConfigurationId = value; + this.filterRawParams[this.selectedConfigurationKey] = value; } config: SearchConfiguration = { @@ -132,17 +163,17 @@ export abstract class BaseQueryBuilderService { protected readonly appConfig: AppConfigService, protected readonly alfrescoApiService: AlfrescoApiService ) { + this.searchMode = 'regular'; + this.userQuery = ''; this.resetToDefaults(); this._queryFragments = this.createQueryFragmentsProxy({}); - - this.populateFilters.subscribe((filters) => this.handleSelectedConfigurationChange(filters)); } public abstract loadConfiguration(): SearchConfiguration | SearchConfiguration[]; public abstract isFilterServiceActive(): boolean; - public resetToDefaults(withNavigate = false) { + public resetToDefaults(withNavigate = false, resetUserQuery = true) { if (withNavigate) { this.router.navigate([], { queryParams: { q: null }, @@ -151,7 +182,7 @@ export abstract class BaseQueryBuilderService { }); } const currentConfig = this.getDefaultConfiguration(); - this.resetSearchOptions(); + this.resetSearchOptions(resetUserQuery); this.configUpdated.next(currentConfig); this.searchForms.next(this.getSearchFormDetails()); this.setUpSearchConfiguration(currentConfig); @@ -170,23 +201,26 @@ export abstract class BaseQueryBuilderService { return configurations; } - public updateSelectedConfiguration(id: string): void { + public updateSelectedConfiguration(id: string, resetFilters = true, shouldExecute = true): void { const currentConfig = this.loadConfiguration(); if (Array.isArray(currentConfig)) { const selectedConfig = currentConfig.find((config) => config.id === id); if (selectedConfig) { + if (resetFilters) { + this.resetSearchOptions(false); + } this.selectedConfigurationId = id; this.searchForms.next(this.getSearchFormDetails()); - this.resetSearchOptions(); this.setUpSearchConfiguration(selectedConfig); - this.filterRawParams[this.selectedConfigurationKey] = id; this.configUpdated.next(selectedConfig); - this.execute(); + if (shouldExecute) { + this.execute(true); + } } } } - private resetSearchOptions(): void { + private resetSearchOptions(resetUserQuery = true): void { this.categories = []; this.queryFragments = {}; this.filterQueries = []; @@ -194,8 +228,10 @@ export abstract class BaseQueryBuilderService { this.sortingOptions = []; this.resetUserFacetBucket(); this.scope = null; - this.filterRawParams = {}; - this._userQuery = ''; + if (resetUserQuery) { + this.userQuery = ''; + } + this.resetFilterRawParams(resetUserQuery); this.populateFilters.next({}); } @@ -229,6 +265,7 @@ export abstract class BaseQueryBuilderService { this.categories = (this.config.categories || []).filter((category) => category.enabled); this.filterQueries = this.config.filterQueries || []; this.userFacetBuckets = {}; + this.userQuery = this.filterRawParams['userQuery'] || ''; if (this.config.sorting) { this.sorting = this.config.sorting.defaults || []; this.sortingOptions = this.config.sorting.options || []; @@ -355,16 +392,6 @@ export abstract class BaseQueryBuilderService { return this.scope; } - /** - * Builds the current query and triggers the `updated` event. - * - * @param queryBody query settings - */ - update(queryBody?: SearchRequest): void { - const query = queryBody ? queryBody : this.buildQuery(); - this.updated.next(query); - } - /** * Builds and executes the current query. * @@ -552,12 +579,9 @@ export abstract class BaseQueryBuilderService { protected getFinalQuery(): string { let query = ''; - if (this.userQuery) { - this.filterRawParams['userQuery'] = this.userQuery; - } this.categories.forEach((facet) => { const customQuery = this.queryFragments[facet.id]; - if (customQuery) { + if (customQuery && JSON.stringify(customQuery) !== JSON.stringify({ matchAll: '', matchAny: '', matchExact: '', exclude: '' })) { if (query.length > 0) { query += ' AND '; } @@ -565,7 +589,8 @@ export abstract class BaseQueryBuilderService { } }); - let result = [this.userQuery, query].filter((entry) => entry).join(' AND '); + const parsedQuery = this.searchMode === 'regular' ? this.parsedQuery : this.userQuery; + let result = [parsedQuery, query].filter((entry) => entry).join(' AND '); if (this.userFacetBuckets) { Object.keys(this.userFacetBuckets).forEach((key) => { @@ -626,10 +651,10 @@ export abstract class BaseQueryBuilderService { */ encodeQuery() { try { - this.encodedQuery = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(this.filterRawParams)))); + this._encodedQuery = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(this.filterRawParams)))); } catch (error) { console.error('Failed to encode query parameters:', error); - this.encodedQuery = ''; + this._encodedQuery = ''; } } @@ -640,7 +665,7 @@ export abstract class BaseQueryBuilderService { this.encodeQuery(); this.router.navigate([], { relativeTo: this.activatedRoute, - queryParams: { q: this.encodedQuery }, + queryParams: { q: this._encodedQuery }, queryParamsHandling: 'merge' }); } @@ -653,50 +678,71 @@ export abstract class BaseQueryBuilderService { */ async navigateToSearch(query: string, searchUrl: string) { this.userQuery = query; + this.encodeQuery(); + await this.execute(); await this.router.navigate([searchUrl], { - queryParams: { q: this.encodedQuery }, + queryParams: { q: this._encodedQuery }, queryParamsHandling: 'merge' }); } + /** + * Checks if string is an AND or OR operator + * + * @param input string to check if it is an operator + * @returns boolean + */ + isOperator(input: string): boolean { + if (input) { + const operators = ['AND', 'OR']; + return operators.includes(input.trim()); + } + return false; + } + private createQueryFragmentsProxy(target: { [key: string]: any }): { [key: string]: any } { return new Proxy(target, this.queryFragmentsHandler); } - private setSelectedConfiguration(id: string): void { - const currentConfig = this.loadConfiguration(); - if (Array.isArray(currentConfig)) { - const selectedConfig = currentConfig.find((config) => config.id === id); - if (selectedConfig) { - this.selectedConfigurationId = id; - this.searchForms.next(this.getSearchFormDetails()); - this.setUpSearchConfiguration(selectedConfig); - this.filterRawParams[this.selectedConfigurationKey] = id; - this.configUpdated.next(selectedConfig); - } - } - } - - private handleSelectedConfigurationChange(filters: { [key: string]: string }): void { - if (Object.keys(filters ?? {}).length === 0) { + private setParsedQuery() { + if (!this.userQuery) { + this._parsedQuery = ''; + this.filterRawParams['parsedQuery'] = ''; return; } - - const newSelectedConfig = filters?.[this.selectedConfigurationKey]; - - if (newSelectedConfig) { - if (newSelectedConfig !== this.selectedConfigurationId) { - this.setSelectedConfiguration(newSelectedConfig); - } + if (this.searchMode === 'formula') { + this._parsedQuery = this.userQuery; } else { - const configurations = this.loadConfiguration(); - if (Array.isArray(configurations)) { - const defaultConfig = configurations.find((config) => config.default); - if (defaultConfig && this.selectedConfigurationId !== defaultConfig.id) { - this.setSelectedConfiguration(defaultConfig.id); - } + const words = this.userQuery.split(/\s+/); + if (words.length > 1) { + const separator = words.some(this.isOperator) ? ' ' : ' AND '; + this._parsedQuery = '(' + words.map((term) => (this.isOperator(term) ? term : this.parseTermByFields(term))).join(separator) + ')'; + } else { + this._parsedQuery = '(' + this.parseTermByFields(this.userQuery) + ')'; } } + this.filterRawParams['parsedQuery'] = this._parsedQuery; + } + + private parseTermByFields(term: string): string { + const suffix = this.wildcardsEnabled ? '*' : ''; + const fields = this.config['app:fields'] || ['cm:name']; + const escapedTerm = this.escapeQueryTerm(term); + return '(' + fields.map((field) => `${field}:"${escapedTerm}${suffix}"`).join(' OR ') + ')'; + } + + private escapeQueryTerm(term: string): string { + return term.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } + + private resetFilterRawParams(resetUserQuery = true) { + this.filterRawParams = { + userQuery: resetUserQuery ? '' : this.userQuery, + parsedQuery: resetUserQuery ? '' : this.parsedQuery, + searchMode: this.searchMode, + [this.selectedConfigurationKey]: this.selectedConfigurationId, + logic: { matchAll: '', matchAny: '', matchExact: '', exclude: '' } + }; } } diff --git a/lib/content-services/src/lib/search/services/search-facet-filters.service.spec.ts b/lib/content-services/src/lib/search/services/search-facet-filters.service.spec.ts index 178b697b19..499b55866f 100644 --- a/lib/content-services/src/lib/search/services/search-facet-filters.service.spec.ts +++ b/lib/content-services/src/lib/search/services/search-facet-filters.service.spec.ts @@ -679,7 +679,7 @@ describe('SearchFacetFiltersService', () => { it('should reset filters and call resetToDefaults', () => { const resetToDefaultsSpy = spyOn(queryBuilder, 'resetToDefaults'); - const updateSpy = spyOn(queryBuilder, 'update'); + const executeSpy = spyOn(queryBuilder, 'execute'); const responseFacets = [{ field: 'field1', label: null }]; const selectedBuckets = [{ field: { field: 'field1', label: null }, bucket: { label: 'bucket1', count: 1, filterQuery: 'q1' } }]; @@ -695,8 +695,8 @@ describe('SearchFacetFiltersService', () => { searchFacetFiltersService.reset(); - expect(resetToDefaultsSpy).toHaveBeenCalled(); - expect(updateSpy).toHaveBeenCalled(); + expect(resetToDefaultsSpy).toHaveBeenCalledWith(true, false); + expect(executeSpy).toHaveBeenCalled(); expect(searchFacetFiltersService.responseFacets).toEqual([]); expect(searchFacetFiltersService.selectedBuckets).toEqual([]); diff --git a/lib/content-services/src/lib/search/services/search-facet-filters.service.ts b/lib/content-services/src/lib/search/services/search-facet-filters.service.ts index ff30d27e8e..0de729bd57 100644 --- a/lib/content-services/src/lib/search/services/search-facet-filters.service.ts +++ b/lib/content-services/src/lib/search/services/search-facet-filters.service.ts @@ -70,8 +70,6 @@ export class SearchFacetFiltersService { this.responseFacets = null; }); - this.queryBuilder.updated.pipe(takeUntilDestroyed()).subscribe((query) => this.queryBuilder.execute(true, query)); - this.queryBuilder.executed.pipe(takeUntilDestroyed()).subscribe((resultSetPaging: ResultSetPaging) => { this.onDataLoaded(resultSetPaging); this.searchService.dataLoaded.next(resultSetPaging); @@ -397,7 +395,7 @@ export class SearchFacetFiltersService { bucket.checked = false; this.queryBuilder.removeUserFacetBucket(facetField?.field, bucket); this.updateSelectedBuckets(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } } @@ -432,7 +430,7 @@ export class SearchFacetFiltersService { this.updateSelectedBuckets(); } }); - this.queryBuilder.update(); + this.queryBuilder.execute(); } resetQueryFragments() { @@ -444,7 +442,7 @@ export class SearchFacetFiltersService { this.responseFacets = []; this.selectedBuckets = []; this.tabbedFacet = null; - this.queryBuilder.resetToDefaults(true); - this.queryBuilder.update(); + this.queryBuilder.resetToDefaults(true, false); + this.queryBuilder.execute(); } } diff --git a/lib/content-services/src/lib/search/services/search-header-query-builder.service.spec.ts b/lib/content-services/src/lib/search/services/search-header-query-builder.service.spec.ts index 8b2032679e..ef26a560aa 100644 --- a/lib/content-services/src/lib/search/services/search-header-query-builder.service.spec.ts +++ b/lib/content-services/src/lib/search/services/search-header-query-builder.service.spec.ts @@ -231,19 +231,17 @@ describe('SearchHeaderQueryBuilderService', () => { spyOn(router, 'navigate'); spyOn(console, 'error'); const searchUrl = 'search'; - builderService.filterRawParams = { - userQuery: '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))' - }; - builderService.encodeQuery(); + const nonLatinQuery = '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))'; + builderService.searchMode = 'formula'; + + await builderService.navigateToSearch(nonLatinQuery, searchUrl); - await builderService.navigateToSearch('', searchUrl); expect(console.error).not.toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith([searchUrl], { - queryParams: { - q: 'eyJ1c2VyUXVlcnkiOiIoKGNtOm5hbWU6XCJ3xIXFvCpcIiBPUiBjbTp0aXRsZTpcInfEhcW8KlwiIE9SIGNtOmRlc2NyaXB0aW9uOlwid8SFxbwqXCIgT1IgVEVYVDpcInfEhcW8KlwiIE9SIFRBRzpcInfEhcW8KlwiKSkifQ==' - }, - queryParamsHandling: 'merge' - }); + const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args; + expect(navigateArgs[0]).toEqual([searchUrl]); + expect(navigateArgs[1].queryParamsHandling).toBe('merge'); + const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q)))); + expect(decoded.userQuery).toBe(nonLatinQuery); }); }); diff --git a/lib/content-services/src/lib/search/services/search-header-query-builder.service.ts b/lib/content-services/src/lib/search/services/search-header-query-builder.service.ts index 7ba51c6a4a..5dd9f9f9d8 100644 --- a/lib/content-services/src/lib/search/services/search-header-query-builder.service.ts +++ b/lib/content-services/src/lib/search/services/search-header-query-builder.service.ts @@ -21,7 +21,6 @@ import { SearchConfiguration } from '../models/search-configuration.interface'; import { BaseQueryBuilderService } from './base-query-builder.service'; import { SearchCategory } from '../models/search-category.interface'; import { Node } from '@alfresco/js-api'; -import { filter } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { SearchSortingDefinition } from '../models/search-sorting-definition.interface'; import { FilterSearch } from '../models/filter-search.interface'; @@ -43,10 +42,6 @@ export class SearchHeaderQueryBuilderService extends BaseQueryBuilderService { const alfrescoApiService = inject(AlfrescoApiService); super(appConfig, alfrescoApiService); - - this.updated.pipe(filter((query) => !!query)).subscribe(() => { - this.execute(); - }); } public isFilterServiceActive(): boolean { diff --git a/lib/content-services/src/lib/search/services/search-query-builder.service.spec.ts b/lib/content-services/src/lib/search/services/search-query-builder.service.spec.ts index b0bb87d341..4661fd0749 100644 --- a/lib/content-services/src/lib/search/services/search-query-builder.service.spec.ts +++ b/lib/content-services/src/lib/search/services/search-query-builder.service.spec.ts @@ -126,16 +126,16 @@ describe('SearchQueryBuilder', () => { expect(builder.userQuery).toBe(''); }); - it('should wrap user query with brackets', () => { + it('should store the user query as the raw value', () => { const builder = createQueryBuilder(); builder.userQuery = 'my query'; - expect(builder.userQuery).toEqual('(my query)'); + expect(builder.userQuery).toEqual('my query'); }); - it('should trim user query value', () => { + it('should expose the user query through filterRawParams', () => { const builder = createQueryBuilder(); - builder.userQuery = ' something '; - expect(builder.userQuery).toEqual('(something)'); + builder.userQuery = 'something'; + expect(builder.filterRawParams['userQuery']).toEqual('something'); }); it('should use only enabled categories', () => { @@ -579,7 +579,8 @@ describe('SearchQueryBuilder', () => { categories: [{ id: 'cat1', enabled: true } as any] }; const builder = createQueryBuilder(config); - builder.userQuery = 'my query'; + builder.searchMode = 'formula'; + builder.userQuery = '(my query)'; builder.queryFragments['cat1'] = 'cm:name:test'; @@ -587,6 +588,21 @@ describe('SearchQueryBuilder', () => { expect(compiled.query.query).toBe('(my query) AND (cm:name:test)'); }); + it('should build final request from the parsed query in regular mode', () => { + const config: SearchConfiguration = { + id: 'test-config', + categories: [{ id: 'cat1', enabled: true } as any] + }; + const builder = createQueryBuilder(config); + builder.searchMode = 'regular'; + builder.userQuery = 'my query'; + + builder.queryFragments['cat1'] = 'cm:name:test'; + + const compiled = builder.buildQuery(); + expect(compiled.query.query).toBe(`${builder.parsedQuery} AND (cm:name:test)`); + }); + it('should group facet buckets by field', () => { const field1: FacetField = { field: 'f1', @@ -729,12 +745,13 @@ describe('SearchQueryBuilder', () => { }); }); - it('should add user query to filter raw params when query is built', () => { + it('should add user query and parsed query to filter raw params when user query is set', () => { const builder = createQueryBuilder(); builder.userQuery = 'nuka cola quantum'; builder.buildQuery(); - expect(builder.filterRawParams).toEqual({ userQuery: '(nuka cola quantum)' }); + expect(builder.filterRawParams['userQuery']).toBe('nuka cola quantum'); + expect(builder.filterRawParams['parsedQuery']).toBe(builder.parsedQuery); }); it('should encode query from filter raw params and update query params on executing query', (done) => { @@ -754,13 +771,14 @@ describe('SearchQueryBuilder', () => { it('should encode query from filter raw params and update query params on navigating to search', async () => { spyOn(router, 'navigate'); - service.filterRawParams = { userQuery: '(test query)' }; + service.searchMode = 'formula'; await service.navigateToSearch('test query', '/search'); - expect(router.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { q: 'eyJ1c2VyUXVlcnkiOiIodGVzdCBxdWVyeSkifQ==' }, - queryParamsHandling: 'merge' - }); + const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args; + expect(navigateArgs[0]).toEqual(['/search']); + expect(navigateArgs[1].queryParamsHandling).toBe('merge'); + const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q)))); + expect(decoded.userQuery).toBe('test query'); }); describe('Multiple search configuration', () => { @@ -859,17 +877,17 @@ describe('SearchQueryBuilder', () => { spyOn(router, 'navigate'); spyOn(console, 'error'); const searchUrl = 'search'; - service.filterRawParams = { userQuery: '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))' }; - service.encodeQuery(); + const nonLatinQuery = '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))'; + service.searchMode = 'formula'; + + await service.navigateToSearch(nonLatinQuery, searchUrl); - await service.navigateToSearch('', searchUrl); expect(console.error).not.toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith([searchUrl], { - queryParams: { - q: 'eyJ1c2VyUXVlcnkiOiIoKGNtOm5hbWU6XCJ3xIXFvCpcIiBPUiBjbTp0aXRsZTpcInfEhcW8KlwiIE9SIGNtOmRlc2NyaXB0aW9uOlwid8SFxbwqXCIgT1IgVEVYVDpcInfEhcW8KlwiIE9SIFRBRzpcInfEhcW8KlwiKSkifQ==' - }, - queryParamsHandling: 'merge' - }); + const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args; + expect(navigateArgs[0]).toEqual([searchUrl]); + expect(navigateArgs[1].queryParamsHandling).toBe('merge'); + const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q)))); + expect(decoded.userQuery).toBe(nonLatinQuery); }); });