From d1462253d0de181be8cc3b6f9e9e106afb3bb13c Mon Sep 17 00:00:00 2001 From: dominikiwanekhyland <141320833+dominikiwanekhyland@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:57:56 +0200 Subject: [PATCH] ADW Saved Search (#10306) Co-authored-by: MichalKinas --- ...earch-chip-autocomplete-input.component.md | 19 +- .../services/search-query-builder.service.md | 11 +- docs/core/services/saved-searches.service.md | 64 +++++ .../interfaces/saved-search.interface.ts | 23 ++ .../src/lib/common/public-api.ts | 2 + .../services/saved-searches.service.spec.ts | 159 +++++++++++ .../common/services/saved-searches.service.ts | 158 +++++++++++ ...de-selector-panel.component-search.spec.ts | 20 +- .../content-node-selector-panel.component.ts | 2 +- .../mock/download-zip-service.mock.ts | 2 +- .../search-check-list.component.spec.ts | 81 +++--- .../search-check-list.component.ts | 52 +++- ...-chip-autocomplete-input.component.spec.ts | 6 + ...earch-chip-autocomplete-input.component.ts | 4 + .../search-date-range-tabbed.component.html | 2 +- ...search-date-range-tabbed.component.spec.ts | 37 ++- .../search-date-range-tabbed.component.ts | 57 +++- .../search-date-range.component.ts | 2 +- .../search-datetime-range.component.spec.ts | 80 +++--- .../search-datetime-range.component.ts | 47 +++- ...h-filter-autocomplete-chips.component.html | 1 + ...ilter-autocomplete-chips.component.spec.ts | 30 ++- ...rch-filter-autocomplete-chips.component.ts | 44 ++- .../search-widget-chip.component.ts | 16 +- .../search-logical-filter.component.spec.ts | 31 ++- .../search-logical-filter.component.ts | 47 +++- .../search-number-range.component.spec.ts | 101 ++++--- .../search-number-range.component.ts | 52 +++- .../search-panel.component.spec.ts | 194 ++------------ .../search-properties.component.html | 1 + .../search-properties.component.spec.ts | 115 +++++++- .../search-properties.component.ts | 123 ++++++--- .../search-radio.component.spec.ts | 71 +++-- .../search-radio/search-radio.component.ts | 30 ++- .../search-slider.component.spec.ts | 106 ++++---- .../search-slider/search-slider.component.ts | 45 +++- .../search-sorting-picker.component.spec.ts | 60 ++--- .../search-text/search-text.component.spec.ts | 32 ++- .../search-text/search-text.component.ts | 42 ++- .../search/models/search-widget.interface.ts | 4 +- .../services/base-query-builder.service.ts | 82 +++++- .../services/search-facet-filters.service.ts | 4 +- ...earch-header-query-builder.service.spec.ts | 90 ++----- .../search-query-builder.service.spec.ts | 252 ++++++++---------- .../src/lib/services/alfresco-api.service.ts | 2 +- 45 files changed, 1591 insertions(+), 812 deletions(-) create mode 100644 docs/core/services/saved-searches.service.md create mode 100644 lib/content-services/src/lib/common/interfaces/saved-search.interface.ts create mode 100644 lib/content-services/src/lib/common/services/saved-searches.service.spec.ts create mode 100644 lib/content-services/src/lib/common/services/saved-searches.service.ts diff --git a/docs/content-services/components/search-chip-autocomplete-input.component.md b/docs/content-services/components/search-chip-autocomplete-input.component.md index e2a638762a3..15bed24db34 100644 --- a/docs/content-services/components/search-chip-autocomplete-input.component.md +++ b/docs/content-services/components/search-chip-autocomplete-input.component.md @@ -25,15 +25,16 @@ Represents an input with autocomplete options. ### Properties -| Name | Type | Default value | Description | -|---------------------------|--------------------------|----|-----------------------------------------------------------------------------------------------------------------------------------------------| -| autocompleteOptions | `AutocompleteOption[]` | [] | Options for autocomplete | -| onReset$ | [`Observable`](https://rxjs.dev/guide/observable)`` | | Observable that will listen to any reset event causing component to clear the chips and input | -| allowOnlyPredefinedValues | boolean | true | A flag that indicates whether it is possible to add a value not from the predefined ones | -| placeholder | string | 'SEARCH.FILTER.ACTIONS.ADD_OPTION' | Placeholder which should be displayed in input. | -| compareOption | (option1: AutocompleteOption, option2: AutocompleteOption) => boolean | | Function which is used to selected options with all options so it allows to detect which options are already selected. | -| formatChipValue | (option: string) => string | | Function which is used to format custom typed options. | -| filter | (options: AutocompleteOption[], value: string) => AutocompleteOption[] | | Function which is used to filter out possible options from hint. By default it checks if option includes typed value and is case insensitive. | +| Name | Type | Default value | Description | +|----------------------------|------------------------------------------------------------------------|----|-----------------------------------------------------------------------------------------------------------------------------------------------| +| autocompleteOptions | `AutocompleteOption[]` | [] | Options for autocomplete | +| preselectedOptions | `AutocompleteOption[]` | [] | Options which are selected from start | +| onReset$ | [`Observable`](https://rxjs.dev/guide/observable)`` | | Observable that will listen to any reset event causing component to clear the chips and input | +| allowOnlyPredefinedValues | boolean | true | A flag that indicates whether it is possible to add a value not from the predefined ones | +| placeholder | string | 'SEARCH.FILTER.ACTIONS.ADD_OPTION' | Placeholder which should be displayed in input. | +| compareOption | (option1: AutocompleteOption, option2: AutocompleteOption) => boolean | | Function which is used to selected options with all options so it allows to detect which options are already selected. | +| formatChipValue | (option: string) => string | | Function which is used to format custom typed options. | +| filter | (options: AutocompleteOption[], value: string) => AutocompleteOption[] | | Function which is used to filter out possible options from hint. By default it checks if option includes typed value and is case insensitive. | ### Events diff --git a/docs/content-services/services/search-query-builder.service.md b/docs/content-services/services/search-query-builder.service.md index 6eb0fa45cee..a6d2c8b3351 100644 --- a/docs/content-services/services/search-query-builder.service.md +++ b/docs/content-services/services/search-query-builder.service.md @@ -23,6 +23,8 @@ Stores information from all the custom search and faceted search widgets, compil - **buildQuery**(): `SearchRequest`
Builds the current query. - **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) @@ -70,7 +72,12 @@ Stores information from all the custom search and faceted search widgets, compil - **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) - + - **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts) - + +- **navigateToSearch**(query: `string`, searchUrl: `string`)
+ Updates user query, executes existing search configuration, encodes the query and navigates to searchUrl. + - _query:_ `string` - The query to use as user query + - _searchUrl:_ `string` - Search url to navigate to - **removeFilterQuery**(query: `string`)
Removes an existing filter query. @@ -93,6 +100,8 @@ Stores information from all the custom search and faceted search widgets, compil - **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` - diff --git a/docs/core/services/saved-searches.service.md b/docs/core/services/saved-searches.service.md new file mode 100644 index 00000000000..90e56f87f2e --- /dev/null +++ b/docs/core/services/saved-searches.service.md @@ -0,0 +1,64 @@ + +# Saved Searches Service + +Manages operations related to saving and retrieving user-defined searches in the Alfresco Process Services (APS) environment. + +## Class members + +### Properties + +- **savedSearches$**: [`ReplaySubject`](https://rxjs.dev/api/index/class/ReplaySubject)``
+ Stores the list of saved searches and emits new value whenever there is a change. + +### Methods + +#### getSavedSearches(): [`Observable`](https://rxjs.dev/api/index/class/Observable)`` + +Fetches the file with list of saved searches either from a locally cached node ID or by querying the APS server. Then it reads the file and maps JSON objects into SavedSearches + +- **Returns**: + - [`Observable`](https://rxjs.dev/api/index/class/Observable)`` - An observable that emits the list of saved searches. + +#### saveSearch(newSaveSearch: Pick): [`Observable`](https://rxjs.dev/api/index/class/Observable)`` + +Saves a new search and updates the existing list of saved searches stored in file and in service property savedSearches$. + +- **Parameters**: + - `newSaveSearch`: An object containing the `name`, `description`, and `encodedUrl` of the new search. + +- **Returns**: + - [`Observable`](https://rxjs.dev/api/index/class/Observable)`` - An observable that emits the response of the node entry after saving. + +### Usage Examples + +#### Fetching Saved Searches + +The following example shows how to fetch saved searches: + +```typescript +this.savedSearchService.getSavedSearches().subscribe((searches: SavedSearch[]) => { + console.log('Saved searches:', searches); +}); +``` + +#### Saving a New Search + +To save a new search: + +```typescript +const newSearch = { name: 'New Search', description: 'A sample search', encodedUrl: 'url3' }; +this.savedSearchService.saveSearch(newSearch).subscribe((response) => { + console.log('Saved new search:', response); +}); +``` + +#### Creating Saved Searches Node + +When the saved searches file does not exist, it will be created: + +```typescript +this.savedSearchService.createSavedSearchesNode('parent-node-id').subscribe((node) => { + console.log('Created saved-searches.json node:', node); +}); +``` + diff --git a/lib/content-services/src/lib/common/interfaces/saved-search.interface.ts b/lib/content-services/src/lib/common/interfaces/saved-search.interface.ts new file mode 100644 index 00000000000..7fe91e0a3ab --- /dev/null +++ b/lib/content-services/src/lib/common/interfaces/saved-search.interface.ts @@ -0,0 +1,23 @@ +/*! + * @license + * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface SavedSearch { + name: string; + description?: string; + encodedUrl: string; + order: number; +} diff --git a/lib/content-services/src/lib/common/public-api.ts b/lib/content-services/src/lib/common/public-api.ts index a433c92cd59..4e06749c0fa 100644 --- a/lib/content-services/src/lib/common/public-api.ts +++ b/lib/content-services/src/lib/common/public-api.ts @@ -24,6 +24,7 @@ export * from './services/nodes-api.service'; export * from './services/discovery-api.service'; export * from './services/people-content.service'; export * from './services/content.service'; +export * from './services/saved-searches.service'; export * from './events/file.event'; @@ -36,4 +37,5 @@ export * from './models/permissions.enum'; export * from './models/allowable-operations.enum'; export * from './interfaces/search-configuration.interface'; +export * from './interfaces/saved-search.interface'; export * from './mocks/ecm-user.service.mock'; diff --git a/lib/content-services/src/lib/common/services/saved-searches.service.spec.ts b/lib/content-services/src/lib/common/services/saved-searches.service.spec.ts new file mode 100644 index 00000000000..b38d0ead959 --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches.service.spec.ts @@ -0,0 +1,159 @@ +/*! + * @license + * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { AlfrescoApiService } from '../../services/alfresco-api.service'; +import { NodeEntry } from '@alfresco/js-api'; +import { SavedSearchesService } from './saved-searches.service'; +import { AlfrescoApiServiceMock } from '@alfresco/adf-content-services'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { AuthenticationService } from '@alfresco/adf-core'; +import { Subject } from 'rxjs'; + +describe('SavedSearchesService', () => { + let service: SavedSearchesService; + let authService: AuthenticationService; + let testUserName: string; + + const testNodeId = 'test-node-id'; + const SAVED_SEARCHES_NODE_ID = 'saved-searches-node-id__'; + const SAVED_SEARCHES_CONTENT = JSON.stringify([ + { name: 'Search 1', description: 'Description 1', encodedUrl: 'url1', order: 0 }, + { name: 'Search 2', description: 'Description 2', encodedUrl: 'url2', order: 1 } + ]); + + /** + * Creates a stub with Promise returning a Blob + * + * @returns Promise with Blob + */ + function createBlob() { + return Promise.resolve(new Blob([SAVED_SEARCHES_CONTENT])); + } + + beforeEach(() => { + testUserName = 'test-user'; + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: AlfrescoApiService, useClass: AlfrescoApiServiceMock }, + { provide: AuthenticationService, useValue: { getUsername: () => {}, onLogin: new Subject() } }, + SavedSearchesService + ] + }); + service = TestBed.inject(SavedSearchesService); + authService = TestBed.inject(AuthenticationService); + spyOn(service.nodesApi, 'getNode').and.callFake(() => Promise.resolve({ entry: { id: testNodeId } } as NodeEntry)); + spyOn(service.searchApi, 'search').and.callFake(() => Promise.resolve({ list: { entries: [] } })); + }); + + afterEach(() => { + localStorage.removeItem(SAVED_SEARCHES_NODE_ID + testUserName); + }); + + it('should retrieve saved searches from the saved-searches.json file', (done) => { + spyOn(authService, 'getUsername').and.callFake(() => testUserName); + spyOn(localStorage, 'getItem').and.callFake(() => testNodeId); + spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob()); + service.innit(); + + service.getSavedSearches().subscribe((searches) => { + expect(localStorage.getItem).toHaveBeenCalledWith(SAVED_SEARCHES_NODE_ID + testUserName); + expect(service.nodesApi.getNodeContent).toHaveBeenCalledWith(testNodeId); + expect(searches.length).toBe(2); + expect(searches[0].name).toBe('Search 1'); + expect(searches[1].name).toBe('Search 2'); + done(); + }); + }); + + it('should create saved-searches.json file if it does not exist', (done) => { + spyOn(authService, 'getUsername').and.callFake(() => testUserName); + spyOn(service.nodesApi, 'createNode').and.callFake(() => Promise.resolve({ entry: { id: 'new-node-id' } })); + spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => Promise.resolve(new Blob(['']))); + service.innit(); + + service.getSavedSearches().subscribe((searches) => { + expect(service.nodesApi.getNode).toHaveBeenCalledWith('-my-'); + expect(service.searchApi.search).toHaveBeenCalled(); + expect(service.nodesApi.createNode).toHaveBeenCalledWith(testNodeId, jasmine.objectContaining({ name: 'saved-searches.json' })); + expect(searches.length).toBe(0); + done(); + }); + }); + + it('should save a new search', (done) => { + spyOn(authService, 'getUsername').and.callFake(() => testUserName); + const nodeId = 'saved-searches-node-id'; + spyOn(localStorage, 'getItem').and.callFake(() => nodeId); + spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob()); + const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' }; + spyOn(service.nodesApi, 'updateNodeContent').and.callFake(() => Promise.resolve({ entry: {} } as NodeEntry)); + service.innit(); + + service.saveSearch(newSearch).subscribe(() => { + expect(service.nodesApi.updateNodeContent).toHaveBeenCalledWith(nodeId, jasmine.any(String)); + expect(service.savedSearches$).toBeDefined(); + service.savedSearches$.subscribe((searches) => { + expect(searches.length).toBe(3); + expect(searches[2].name).toBe('Search 3'); + expect(searches[2].order).toBe(2); + done(); + }); + }); + }); + + it('should emit initial saved searches on subscription', (done) => { + const nodeId = 'saved-searches-node-id'; + spyOn(localStorage, 'getItem').and.returnValue(nodeId); + spyOn(service.nodesApi, 'getNodeContent').and.returnValue(createBlob()); + service.innit(); + + service.savedSearches$.pipe().subscribe((searches) => { + expect(searches.length).toBe(2); + expect(searches[0].name).toBe('Search 1'); + done(); + }); + + service.getSavedSearches().subscribe(); + }); + + it('should emit updated saved searches after saving a new search', (done) => { + spyOn(authService, 'getUsername').and.callFake(() => testUserName); + spyOn(localStorage, 'getItem').and.callFake(() => testNodeId); + spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob()); + const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' }; + spyOn(service.nodesApi, 'updateNodeContent').and.callFake(() => Promise.resolve({ entry: {} } as NodeEntry)); + service.innit(); + + let emissionCount = 0; + + service.savedSearches$.subscribe((searches) => { + emissionCount++; + if (emissionCount === 1) { + expect(searches.length).toBe(2); + } + if (emissionCount === 2) { + expect(searches.length).toBe(3); + expect(searches[2].name).toBe('Search 3'); + done(); + } + }); + + service.saveSearch(newSearch).subscribe(); + }); +}); diff --git a/lib/content-services/src/lib/common/services/saved-searches.service.ts b/lib/content-services/src/lib/common/services/saved-searches.service.ts new file mode 100644 index 00000000000..71ae6fe721e --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches.service.ts @@ -0,0 +1,158 @@ +/*! + * @license + * Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NodesApi, NodeEntry, SearchApi, SEARCH_LANGUAGE, ResultSetPaging } from '@alfresco/js-api'; +import { Injectable } from '@angular/core'; +import { Observable, of, from, ReplaySubject, throwError } from 'rxjs'; +import { catchError, concatMap, first, map, switchMap, take, tap } from 'rxjs/operators'; +import { AlfrescoApiService } from '../../services/alfresco-api.service'; +import { SavedSearch } from '../interfaces/saved-search.interface'; +import { AuthenticationService } from '@alfresco/adf-core'; + +@Injectable({ + providedIn: 'root' +}) +export class SavedSearchesService { + private _searchApi: SearchApi; + get searchApi(): SearchApi { + this._searchApi = this._searchApi ?? new SearchApi(this.apiService.getInstance()); + return this._searchApi; + } + + private _nodesApi: NodesApi; + get nodesApi(): NodesApi { + this._nodesApi = this._nodesApi ?? new NodesApi(this.apiService.getInstance()); + return this._nodesApi; + } + + readonly savedSearches$ = new ReplaySubject(1); + + private savedSearchFileNodeId: string; + private currentUserLocalStorageKey: string; + private createFileAttempt = false; + + constructor(private readonly apiService: AlfrescoApiService, private readonly authService: AuthenticationService) {} + + innit(): void { + this.fetchSavedSearches(); + } + + /** + * Gets a list of saved searches by user. + * + * @returns SavedSearch list containing user saved searches + */ + getSavedSearches(): Observable { + return this.getSavedSearchesNodeId().pipe( + concatMap(() => { + return from( + this.nodesApi.getNodeContent(this.savedSearchFileNodeId).then((content) => this.mapFileContentToSavedSearches(content)) + ).pipe( + catchError((error) => { + if (!this.createFileAttempt) { + this.createFileAttempt = true; + localStorage.removeItem(this.getLocalStorageKey()); + return this.getSavedSearches(); + } + return throwError(() => error); + }) + ); + }) + ); + } + + /** + * Gets a list of saved searches by user. + * + * @param newSaveSearch object { name: string, description: string, encodedUrl: string } + * @returns Adds and saves search also updating current saved search state + */ + saveSearch(newSaveSearch: Pick): Observable { + return this.getSavedSearches().pipe( + take(1), + switchMap((savedSearches: Array) => { + const updatedSavedSearches = [...savedSearches, { ...newSaveSearch, order: savedSearches.length }]; + return from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(updatedSavedSearches))).pipe( + tap(() => this.savedSearches$.next(updatedSavedSearches)) + ); + }) + ); + } + + private getSavedSearchesNodeId(): Observable { + const localStorageKey = this.getLocalStorageKey(); + if (this.currentUserLocalStorageKey && this.currentUserLocalStorageKey !== localStorageKey) { + this.savedSearches$.next([]); + } + this.currentUserLocalStorageKey = localStorageKey; + let savedSearchesNodeId = localStorage.getItem(this.currentUserLocalStorageKey) ?? ''; + if (savedSearchesNodeId === '') { + return from(this.nodesApi.getNode('-my-')).pipe( + first(), + map((node) => node.entry.id), + concatMap((parentNodeId) => + from( + this.searchApi.search({ + query: { + language: SEARCH_LANGUAGE.AFTS, + query: `cm:name:"saved-searches.json" AND PARENT:"${parentNodeId}"` + } + }) + ).pipe( + first(), + concatMap((searchResult: ResultSetPaging) => { + if (searchResult.list.entries.length > 0) { + savedSearchesNodeId = searchResult.list.entries[0].entry.id; + localStorage.setItem(this.currentUserLocalStorageKey, savedSearchesNodeId); + } else { + return this.createSavedSearchesNode(parentNodeId).pipe( + first(), + map((node) => { + localStorage.setItem(this.currentUserLocalStorageKey, node.entry.id); + return node.entry.id; + }) + ); + } + this.savedSearchFileNodeId = savedSearchesNodeId; + return savedSearchesNodeId; + }) + ) + ) + ); + } else { + this.savedSearchFileNodeId = savedSearchesNodeId; + return of(savedSearchesNodeId); + } + } + private createSavedSearchesNode(parentNodeId: string): Observable { + return from(this.nodesApi.createNode(parentNodeId, { name: 'saved-searches.json', nodeType: 'cm:content' })); + } + + private async mapFileContentToSavedSearches(blob: Blob): Promise> { + return blob.text().then((content) => (content ? JSON.parse(content) : [])); + } + + private getLocalStorageKey(): string { + return `saved-searches-node-id__${this.authService.getUsername()}`; + } + + private fetchSavedSearches(): void { + this.getSavedSearches() + .pipe(take(1)) + .subscribe((searches) => this.savedSearches$.next(searches)); + } +} 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 8068d9c70f9..81517b24bf1 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 @@ -174,7 +174,7 @@ describe('ContentNodeSelectorPanelComponent', () => { tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(mockSearchRequest); + expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); })); it('should NOT perform a search and clear the results when the search request gets updated and it is NOT defined', async () => { @@ -212,7 +212,7 @@ describe('ContentNodeSelectorPanelComponent', () => { tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(mockSearchRequest); + expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); })); it('should the query include the show files filterQuery', fakeAsync(() => { @@ -227,7 +227,7 @@ describe('ContentNodeSelectorPanelComponent', () => { tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); })); it('should reset the currently chosen node in case of starting a new search', fakeAsync(() => { @@ -261,7 +261,7 @@ describe('ContentNodeSelectorPanelComponent', () => { expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/namek'` }]; expect(searchSpy.calls.count()).toBe(2); - expect(searchSpy).toHaveBeenCalledWith(expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); })); it('should create the query with the right parameters on changing the site selectBox value from a custom dropdown menu', fakeAsync(() => { @@ -286,8 +286,8 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(searchSpy).toHaveBeenCalled(); expect(searchSpy.calls.count()).toBe(2); - expect(searchSpy).toHaveBeenCalledWith(mockSearchRequest); - expect(searchSpy).toHaveBeenCalledWith(expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); + expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); })); it('should get the corresponding node ids on search when a known alias is selected from dropdown', fakeAsync(() => { @@ -407,7 +407,7 @@ describe('ContentNodeSelectorPanelComponent', () => { const expectedRequest = mockSearchRequest; expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/my-root-id'` }]; - expect(searchSpy).toHaveBeenCalledWith(expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); })); it('should emit showingSearch event with true while searching', async () => { @@ -416,7 +416,7 @@ describe('ContentNodeSelectorPanelComponent', () => { spyOn(customResourcesService, 'hasCorrespondingNodeIds').and.returnValue(true); const showingSearchSpy = spyOn(component.showingSearch, 'emit'); - await searchQueryBuilderService.execute({ query: { query: 'search' } }); + await searchQueryBuilderService.execute(true, { query: { query: 'search' } }); triggerSearchResults(fakeResultSetPaging); fixture.detectChanges(); @@ -460,7 +460,7 @@ describe('ContentNodeSelectorPanelComponent', () => { searchQueryBuilderService.update(); getCorrespondingNodeIdsSpy.and.throwError('Failed'); const showingSearchSpy = spyOn(component.showingSearch, 'emit'); - await searchQueryBuilderService.execute({ query: { query: 'search' } }); + await searchQueryBuilderService.execute(true, { query: { query: 'search' } }); triggerSearchResults(fakeResultSetPaging); fixture.detectChanges(); @@ -479,7 +479,7 @@ describe('ContentNodeSelectorPanelComponent', () => { const expectedRequest = mockSearchRequest; expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/my-site-id'` }]; - expect(searchSpy).toHaveBeenCalledWith(expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); }); it('should restrict the breadcrumb to the currentFolderId in case restrictedRoot is true', async () => { 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 1308497778d..defd4222dff 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 @@ -343,7 +343,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit, OnDestroy { if (searchRequest) { this.hasValidQuery = true; this.prepareDialogForNewSearch(searchRequest); - this.queryBuilderService.execute(searchRequest); + this.queryBuilderService.execute(false, searchRequest); } else { this.hasValidQuery = false; this.resetFolderToShow(); diff --git a/lib/content-services/src/lib/dialogs/download-zip/mock/download-zip-service.mock.ts b/lib/content-services/src/lib/dialogs/download-zip/mock/download-zip-service.mock.ts index 1b31f2746f0..815116b6aeb 100644 --- a/lib/content-services/src/lib/dialogs/download-zip/mock/download-zip-service.mock.ts +++ b/lib/content-services/src/lib/dialogs/download-zip/mock/download-zip-service.mock.ts @@ -22,7 +22,7 @@ import { zipNode, downloadEntry } from './download-zip-data.mock'; export class AlfrescoApiServiceMock { nodeUpdated = new Subject(); - alfrescoApiInitialized: ReplaySubject = new ReplaySubject(1); + alfrescoApiInitialized = new ReplaySubject(1); alfrescoApi = new AlfrescoApiMock(); load() {} 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 aac9ce96795..36eb1808260 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 @@ -24,6 +24,7 @@ import { HarnessLoader, TestKey } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; import { MatButtonHarness } from '@angular/material/button/testing'; +import { ReplaySubject } from 'rxjs'; describe('SearchCheckListComponent', () => { let loader: HarnessLoader; @@ -37,6 +38,13 @@ describe('SearchCheckListComponent', () => { fixture = TestBed.createComponent(SearchCheckListComponent); component = fixture.componentInstance; loader = TestbedHarnessEnvironment.loader(fixture); + + component.context = { + queryFragments: {}, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy() + } as any; }); it('should setup options from settings', () => { @@ -87,22 +95,17 @@ describe('SearchCheckListComponent', () => { ]); component.id = 'checklist'; - component.context = { - queryFragments: {}, - update: () => {} - } as any; - component.ngOnInit(); - spyOn(component.context, 'update').and.stub(); - component.changeHandler({ checked: true } as any, component.options.items[0]); expect(component.context.queryFragments[component.id]).toEqual(`TYPE:'cm:folder'`); + expect(component.context.filterRawParams[component.id]).toEqual([`TYPE:'cm:folder'`]); component.changeHandler({ checked: true } as any, component.options.items[1]); expect(component.context.queryFragments[component.id]).toEqual(`TYPE:'cm:folder' OR TYPE:'cm:content'`); + expect(component.context.filterRawParams[component.id]).toEqual([`TYPE:'cm:folder'`, `TYPE:'cm:content'`]); }); it('should reset selected boxes', () => { @@ -119,13 +122,8 @@ describe('SearchCheckListComponent', () => { it('should update query builder on reset', () => { component.id = 'checklist'; - component.context = { - queryFragments: { - checklist: 'query' - }, - update: () => {} - } as any; - spyOn(component.context, 'update').and.stub(); + component.context.queryFragments[component.id] = 'query'; + component.context.filterRawParams[component.id] = 'test'; component.ngOnInit(); component.options = new SearchFilterList([ @@ -137,17 +135,13 @@ describe('SearchCheckListComponent', () => { expect(component.context.update).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); describe('Pagination', () => { it('should show 5 items when pageSize not defined', async () => { component.id = 'checklist'; - component.context = { - queryFragments: { - checklist: 'query' - }, - update: () => {} - } as any; + component.context.queryFragments[component.id] = 'query'; component.settings = { options: sizeOptions } as any; component.ngOnInit(); @@ -162,12 +156,7 @@ describe('SearchCheckListComponent', () => { it('should show all items when pageSize is high', async () => { component.id = 'checklist'; - component.context = { - queryFragments: { - checklist: 'query' - }, - update: () => {} - } as any; + component.context.queryFragments[component.id] = 'query'; component.settings = { pageSize: 15, options: sizeOptions } as any; component.ngOnInit(); fixture.detectChanges(); @@ -182,12 +171,7 @@ describe('SearchCheckListComponent', () => { it('should able to check/reset the checkbox', async () => { component.id = 'checklist'; - component.context = { - queryFragments: { - checklist: 'query' - }, - update: () => {} - } as any; + component.context.queryFragments[component.id] = 'query'; component.settings = { options: sizeOptions } as any; spyOn(component, 'submitValues').and.stub(); component.ngOnInit(); @@ -212,10 +196,7 @@ describe('SearchCheckListComponent', () => { { name: 'Document', value: `TYPE:'cm:content'`, checked: false } ]); component.startValue = `TYPE:'cm:folder'`; - component.context = { - queryFragments: {}, - update: jasmine.createSpy() - } as any; + component.context.queryFragments[component.id] = 'query'; fixture.detectChanges(); expect(component.context.queryFragments[component.id]).toBe(`TYPE:'cm:folder'`); @@ -229,15 +210,31 @@ describe('SearchCheckListComponent', () => { { name: 'Document', value: `TYPE:'cm:content'`, checked: false } ]); component.startValue = undefined; - component.context = { - queryFragments: { - checkList: `TYPE:'cm:folder'` - }, - update: jasmine.createSpy() - } as any; + component.context.queryFragments[component.id] = `TYPE:'cm:folder'`; fixture.detectChanges(); expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.update).not.toHaveBeenCalled(); }); + + it('should populate filter state when populate filters event has been observed', () => { + component.id = 'checkList'; + component.options = new SearchFilterList([ + { name: 'Folder', value: `TYPE:'cm:folder'`, checked: false }, + { name: 'Document', value: `TYPE:'cm:content'`, checked: false } + ]); + component.startValue = undefined; + component.context.filterLoaded = new ReplaySubject(1); + spyOn(component.context.filterLoaded, 'next').and.stub(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + + component.context.populateFilters.next({ checkList: [`TYPE:'cm:content'`] }); + fixture.detectChanges(); + + expect(component.options.items[1].checked).toBeTrue(); + expect(component.displayValue$.next).toHaveBeenCalledWith('Document'); + expect(component.context.filterRawParams[component.id]).toEqual([`TYPE:'cm:content'`]); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); + }); }); 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 03ecf8579ca..9b0f8fc63be 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 @@ -15,14 +15,15 @@ * limitations under the License. */ -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; import { SearchFilterList } from '../../models/search-filter-list.model'; import { TranslationService } from '@alfresco/adf-core'; -import { Subject } from 'rxjs'; +import { ReplaySubject, Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { MatButtonModule } from '@angular/material/button'; @@ -43,7 +44,7 @@ export interface SearchListOption { encapsulation: ViewEncapsulation.None, host: { class: 'adf-search-check-list' } }) -export class SearchCheckListComponent implements SearchWidget, OnInit { +export class SearchCheckListComponent implements SearchWidget, OnInit, OnDestroy { id: string; settings?: SearchWidgetSettings; context?: SearchQueryBuilderService; @@ -53,7 +54,9 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { pageSize = 5; isActive = false; enableChangeUpdate = true; - displayValue$: Subject = new Subject(); + displayValue$ = new ReplaySubject(1); + + private readonly destroy$ = new Subject(); constructor(private translationService: TranslationService) { this.options = new SearchFilterList(); @@ -77,6 +80,31 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { this.context.queryFragments[this.id] = ''; } } + this.context.populateFilters + .asObservable() + .pipe( + map((filtersQueries) => filtersQueries[this.id]), + takeUntil(this.destroy$) + ) + .subscribe((filterQuery) => { + if (filterQuery) { + filterQuery.forEach((value) => { + const option = this.options.items.find((searchListOption) => searchListOption.value === value); + if (option) { + option.checked = true; + } + }); + this.submitValues(false); + } else { + this.reset(false); + } + this.context.filterLoaded.next(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } clear() { @@ -95,15 +123,18 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { if (this.id && this.context) { this.context.queryFragments[this.id] = ''; + this.context.filterRawParams[this.id] = undefined; } } - reset() { + reset(updateContext = true) { this.isActive = false; this.clearOptions(); if (this.id && this.context) { this.updateDisplayValue(); - this.context.update(); + if (updateContext) { + this.context.update(); + } } } @@ -142,13 +173,18 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { return this.options.items.filter((option) => option.checked).map((option) => option.value); } - submitValues() { + submitValues(updateContext = true) { const checkedValues = this.getCheckedValues(); + if (checkedValues.length !== 0) { + this.context.filterRawParams[this.id] = checkedValues; + } const query = checkedValues.join(` ${this.operator} `); if (this.id && this.context) { this.context.queryFragments[this.id] = query; this.updateDisplayValue(); - this.context.update(); + if (updateContext) { + this.context.update(); + } } } } 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 da598ee6fe0..32fedacd276 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 @@ -122,6 +122,12 @@ describe('SearchChipAutocompleteInputComponent', () => { return fixture.debugElement.queryAll(By.css('.adf-autocomplete-added-option')); } + it('should assign preselected values to selected options on init', () => { + component.preselectedOptions = [{ value: 'option1' }]; + component.ngOnInit(); + expect(component.selectedOptions).toEqual([{ value: 'option1' }]); + }); + it('should add new option only if value is predefined when allowOnlyPredefinedValues = true', async () => { addNewOption('test'); addNewOption('option1'); 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 b3e57fdcd25..2a3f3a24ed4 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 @@ -55,6 +55,9 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy, @Input() autocompleteOptions: AutocompleteOption[] = []; + @Input() + preselectedOptions: AutocompleteOption[] = []; + @Input() onReset$: Observable; @@ -106,6 +109,7 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy, this.inputChanged.emit(value); }); this.onReset$?.pipe(takeUntil(this.onDestroy$)).subscribe(() => this.reset()); + this.selectedOptions = this.preselectedOptions ?? []; } ngOnChanges(changes: SimpleChanges) { diff --git a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.html b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.html index c45ac56703b..b061ed8d52b 100644 --- a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.html +++ b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.html @@ -5,7 +5,7 @@ [dateFormat]="settings.dateFormat" [maxDate]="settings.maxDate" [field]="field" - [initialValue]="startValue" + [initialValue]="preselectedValues[field]" (changed)="onDateRangedValueChanged($event, field)" (valid)="tabsValidity[field]=$event"> 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 03810ec4572..83b32377497 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 @@ -25,6 +25,7 @@ import { SearchDateRangeTabbedComponent } from './search-date-range-tabbed.compo import { DateRangeType } from './search-date-range/date-range-type'; import { InLastDateType } from './search-date-range/in-last-date-type'; import { endOfDay, endOfToday, formatISO, parse, startOfDay, startOfMonth, startOfWeek, subDays, subMonths, subWeeks } from 'date-fns'; +import { ReplaySubject } from 'rxjs'; @Component({ selector: 'adf-search-filter-tabbed', @@ -75,13 +76,15 @@ describe('SearchDateRangeTabbedComponent', () => { queryFragments: { dateRange: '' }, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), update: jasmine.createSpy('update') } as any; component.settings = { hideDefaultAction: false, dateFormat: 'dd-MMM-yy', maxDate: 'today', - field: 'createdDate, modifiedDate', + field: 'createdDate,modifiedDate', displayedLabelsByField: { createdDate: 'Created Date', modifiedDate: 'Modified Date' @@ -163,6 +166,8 @@ describe('SearchDateRangeTabbedComponent', () => { `createdDate:['${formatISO(startOfDay(betweenMockData.betweenStartDate))}' TO '${formatISO(endOfDay(betweenMockData.betweenEndDate))}']` + ` AND modifiedDate:['${formatISO(startOfDay(inLastStartDate))}' TO '${formatISO(endOfToday())}']`; expect(component.combinedQuery).toEqual(query); + expect(component.context.filterRawParams[component.id].createdDate).toEqual(betweenMockData); + expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(inLastMockData); inLastMockData = { dateRangeType: DateRangeType.IN_LAST, @@ -178,6 +183,7 @@ describe('SearchDateRangeTabbedComponent', () => { `createdDate:['${formatISO(startOfDay(betweenMockData.betweenStartDate))}' TO '${formatISO(endOfDay(betweenMockData.betweenEndDate))}']` + ` AND modifiedDate:['${formatISO(startOfDay(inLastStartDate))}' TO '${formatISO(endOfToday())}']`; expect(component.combinedQuery).toEqual(query); + expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(inLastMockData); inLastMockData = { dateRangeType: DateRangeType.IN_LAST, @@ -193,12 +199,14 @@ describe('SearchDateRangeTabbedComponent', () => { `createdDate:['${formatISO(startOfDay(betweenMockData.betweenStartDate))}' TO '${formatISO(endOfDay(betweenMockData.betweenEndDate))}']` + ` AND modifiedDate:['${formatISO(startOfDay(inLastStartDate))}' TO '${formatISO(endOfToday())}']`; expect(component.combinedQuery).toEqual(query); - expect(component.combinedQuery).toEqual(query); + expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(inLastMockData); component.onDateRangedValueChanged(anyMockDate, 'createdDate'); component.onDateRangedValueChanged(anyMockDate, 'modifiedDate'); fixture.detectChanges(); expect(component.combinedQuery).toEqual(''); + expect(component.context.filterRawParams[component.id].createdDate).toEqual(anyMockDate); + expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(anyMockDate); }); it('should trigger context.update() when values are submitted', () => { @@ -224,6 +232,31 @@ describe('SearchDateRangeTabbedComponent', () => { expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.context.queryFragments['dateRange']).toEqual(''); expect(component.context.update).toHaveBeenCalled(); + component.fields.forEach((field) => expect(component.context.filterRawParams[field]).toBeUndefined()); + }); + + it('should populate filter state when populate filters event has been observed', () => { + component.context.filterLoaded = new ReplaySubject(1); + spyOn(component.context.filterLoaded, 'next').and.stub(); + spyOn(component.displayValue$, 'next').and.stub(); + const createdDateMock = { + dateRangeType: DateRangeType.BETWEEN, + inLastValueType: InLastDateType.DAYS, + inLastValue: undefined, + betweenStartDate: '2023-06-05', + betweenEndDate: '2023-06-07' + }; + component.context.populateFilters.next({ dateRange: { createdDate: createdDateMock, modifiedDate: inLastMockData } }); + fixture.detectChanges(); + + expect(component.displayValue$.next).toHaveBeenCalledWith( + 'CREATED DATE: 05-Jun-23 - 07-Jun-23 MODIFIED DATE: SEARCH.DATE_RANGE_ADVANCED.IN_LAST_DISPLAY_LABELS.WEEKS' + ); + expect(component.preselectedValues['createdDate']).toEqual(betweenMockData); + expect(component.preselectedValues['modifiedDate']).toEqual(inLastMockData); + expect(component.context.filterRawParams[component.id].createdDate).toEqual(betweenMockData); + expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(inLastMockData); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); }); describe('SearchDateRangeTabbedComponent getTabLabel', () => { 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 8f278171495..37b870cbfc2 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 @@ -15,8 +15,9 @@ * limitations under the License. */ -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; -import { Subject } from 'rxjs'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { ReplaySubject, Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { DateRangeType } from './search-date-range/date-range-type'; import { SearchDateRange } from './search-date-range/search-date-range'; import { SearchWidget } from '../../models/search-widget.interface'; @@ -24,7 +25,7 @@ import { SearchWidgetSettings } from '../../models/search-widget-settings.interf import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; import { InLastDateType } from './search-date-range/in-last-date-type'; import { TranslationService } from '@alfresco/adf-core'; -import { endOfDay, endOfToday, format, formatISO, startOfDay, startOfMonth, startOfWeek, subDays, subMonths, subWeeks } from 'date-fns'; +import { endOfDay, endOfToday, format, formatISO, parseISO, startOfDay, startOfMonth, startOfWeek, subDays, subMonths, subWeeks } from 'date-fns'; import { CommonModule } from '@angular/common'; import { SearchFilterTabbedComponent } from '../search-filter-tabbed/search-filter-tabbed.component'; import { SearchDateRangeComponent } from './search-date-range/search-date-range.component'; @@ -40,8 +41,8 @@ const DEFAULT_DATE_DISPLAY_FORMAT = 'dd-MMM-yy'; styleUrls: ['./search-date-range-tabbed.component.scss'], encapsulation: ViewEncapsulation.None }) -export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit { - displayValue$ = new Subject(); +export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit, OnDestroy { + displayValue$ = new ReplaySubject(1); id: string; startValue: SearchDateRange = { dateRangeType: DateRangeType.ANY, @@ -50,6 +51,7 @@ export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit { betweenStartDate: undefined, betweenEndDate: undefined }; + preselectedValues: { [key: string]: SearchDateRange } = {}; settings?: SearchWidgetSettings; context?: SearchQueryBuilderService; fields: string[]; @@ -60,12 +62,42 @@ export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit { private value: { [key: string]: Partial } = {}; private queryMapByField: Map = new Map(); private displayValueMapByField: Map = new Map(); + private readonly destroy$ = new Subject(); constructor(private translateService: TranslationService) {} ngOnInit(): void { this.fields = this.settings?.field.split(',').map((field) => field.trim()); this.setDefaultDateFormatSettings(); + this.context.populateFilters + .asObservable() + .pipe( + map((filtersQueries) => filtersQueries[this.id]), + takeUntil(this.destroy$) + ) + .subscribe((filterQuery) => { + if (filterQuery) { + Object.keys(filterQuery).forEach((field) => { + filterQuery[field].betweenStartDate = filterQuery[field].betweenStartDate + ? parseISO(filterQuery[field].betweenStartDate) + : undefined; + filterQuery[field].betweenEndDate = filterQuery[field].betweenEndDate + ? parseISO(filterQuery[field].betweenEndDate) + : undefined; + this.preselectedValues[field] = filterQuery[field]; + this.onDateRangedValueChanged(filterQuery[field], field); + }); + this.submitValues(false); + } else { + this.reset(false); + } + this.context.filterLoaded.next(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } private setDefaultDateFormatSettings() { @@ -82,13 +114,18 @@ export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit { return Object.values(this.tabsValidity).every((valid) => valid); } - reset() { + reset(updateContext = true) { this.combinedQuery = ''; this.combinedDisplayValue = ''; this.startValue = { ...this.startValue }; - this.submitValues(); + this.fields.forEach((field) => { + this.context.filterRawParams[field] = undefined; + }); + this.context.queryFragments[this.id] = undefined; + this.context.filterRawParams[this.id] = undefined; + this.submitValues(updateContext); } setValue(value: { [key: string]: SearchDateRange }) { @@ -99,14 +136,16 @@ export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit { return this.settings?.displayedLabelsByField?.[field] ? this.settings.displayedLabelsByField[field] : field; } - submitValues() { + submitValues(updateContext = true) { this.context.queryFragments[this.id] = this.combinedQuery; this.displayValue$.next(this.combinedDisplayValue); - if (this.id && this.context) { + if (this.id && this.context && updateContext) { this.context.update(); } } onDateRangedValueChanged(value: Partial, field: string) { + this.context.filterRawParams[this.id] ||= {}; + this.context.filterRawParams[this.id][field] = value; this.value[field] = value; this.updateQuery(value, field); this.updateDisplayValue(value, field); diff --git a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range/search-date-range.component.ts b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range/search-date-range.component.ts index b4feca97db3..0a8fb591fb8 100644 --- a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range/search-date-range.component.ts +++ b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range/search-date-range.component.ts @@ -87,7 +87,7 @@ export class SearchDateRangeComponent implements OnInit, OnDestroy { betweenStartDateFormControl = this.form.controls.betweenStartDate; betweenEndDateFormControl = this.form.controls.betweenEndDate; convertedMaxDate: Date; - private destroy$ = new Subject(); + private readonly destroy$ = new Subject(); readonly DateRangeType = DateRangeType; readonly InLastDateType = InLastDateType; 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 217164a15bc..19907ec2b33 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 @@ -20,7 +20,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ContentTestingModule } from '../../../testing/content.testing.module'; import { MatDatetimepickerInputEvent } from '@mat-datetimepicker/core'; import { DateFnsUtils } from '@alfresco/adf-core'; -import { isValid } from 'date-fns'; +import { endOfMinute, isValid, startOfMinute } from 'date-fns'; +import { ReplaySubject } from 'rxjs'; describe('SearchDatetimeRangeComponent', () => { let fixture: ComponentFixture; @@ -36,6 +37,16 @@ describe('SearchDatetimeRangeComponent', () => { }); fixture = TestBed.createComponent(SearchDatetimeRangeComponent); component = fixture.componentInstance; + component.id = 'createdDateRange'; + component.context = { + queryFragments: { + createdDatetimeRange: '' + }, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy('update') + } as any; + component.settings = { field: 'cm:created' }; }); afterEach(() => fixture.destroy()); @@ -76,7 +87,7 @@ describe('SearchDatetimeRangeComponent', () => { expect(component.from.value).toBeNull(); }); - it('should reset form', async () => { + it('should reset form and filter params', async () => { fixture.detectChanges(); await fixture.whenStable(); @@ -93,6 +104,7 @@ describe('SearchDatetimeRangeComponent', () => { expect(component.from.value).toBeNull(); expect(component.to.value).toBeNull(); expect(component.form.value).toEqual({ from: null, to: null }); + expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); it('should reset fromMaxDatetime on reset', async () => { @@ -106,39 +118,19 @@ describe('SearchDatetimeRangeComponent', () => { }); it('should update query builder on reset', async () => { - const context: any = { - queryFragments: { - createdDatetimeRange: 'query' - }, - update: () => {} - }; - - component.id = 'createdDatetimeRange'; - component.context = context; - - spyOn(context, 'update').and.stub(); + component.context.queryFragments[component.id] = 'query'; fixture.detectChanges(); await fixture.whenStable(); component.reset(); - expect(context.queryFragments.createdDatetimeRange).toEqual(''); - expect(context.update).toHaveBeenCalled(); + expect(component.context.queryFragments.createdDatetimeRange).toEqual(''); + expect(component.context.update).toHaveBeenCalled(); }); it('should update the query in UTC format when values change', async () => { - const context: any = { - queryFragments: {}, - update: () => {} - }; - - component.id = 'createdDateRange'; - component.context = context; component.settings = { field: 'cm:created' }; - - spyOn(context, 'update').and.stub(); - fixture.detectChanges(); await fixture.whenStable(); @@ -151,25 +143,19 @@ describe('SearchDatetimeRangeComponent', () => { ); const expectedQuery = `cm:created:['2016-10-16T12:30:00.000Z' TO '2017-10-16T20:00:59.000Z']`; + const expectedFromDate = DateFnsUtils.utcToLocal(startOfMinute(fromDatetime)).toISOString(); + const expectedToDate = DateFnsUtils.utcToLocal(endOfMinute(toDatetime)).toISOString(); - expect(context.queryFragments[component.id]).toEqual(expectedQuery); - expect(context.update).toHaveBeenCalled(); + expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); + expect(component.context.filterRawParams[component.id]).toEqual({ start: expectedFromDate, end: expectedToDate }); + expect(component.context.update).toHaveBeenCalled(); }); it('should be able to update the query in UTC format from a GMT format', async () => { - const context: any = { - queryFragments: {}, - update: () => {} - }; const fromInGmt = new Date('2021-02-24T17:00:00+02:00'); const toInGmt = new Date('2021-02-28T15:00:00+02:00'); - - component.id = 'createdDateRange'; - component.context = context; component.settings = { field: 'cm:created' }; - spyOn(context, 'update').and.stub(); - fixture.detectChanges(); await fixture.whenStable(); @@ -183,8 +169,8 @@ describe('SearchDatetimeRangeComponent', () => { const expectedQuery = `cm:created:['2021-02-24T15:00:00.000Z' TO '2021-02-28T13:00:59.000Z']`; - expect(context.queryFragments[component.id]).toEqual(expectedQuery); - expect(context.update).toHaveBeenCalled(); + expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); + expect(component.context.update).toHaveBeenCalled(); }); it('should show datetime-format error when an invalid datetime is set', async () => { @@ -232,4 +218,22 @@ describe('SearchDatetimeRangeComponent', () => { expect(inputs[1]).toBeDefined(); expect(inputs[1]).not.toBeNull(); }); + + it('should populate filter state when populate filters event has been observed', () => { + component.context.filterLoaded = new ReplaySubject(1); + spyOn(component.context.filterLoaded, 'next').and.stub(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + const fromDateString = startOfMinute(fromDatetime).toISOString(); + const toDateString = endOfMinute(toDatetime).toISOString(); + const expectedFromDate = DateFnsUtils.utcToLocal(startOfMinute(fromDatetime)).toISOString(); + const expectedToDate = DateFnsUtils.utcToLocal(endOfMinute(toDatetime)).toISOString(); + component.context.populateFilters.next({ createdDateRange: { start: fromDateString, end: toDateString } }); + fixture.detectChanges(); + + expect(component.displayValue$.next).toHaveBeenCalledWith('16/10/2016 12:30 - 16/10/2017 20:00'); + expect(component.context.filterRawParams[component.id].start).toEqual(expectedFromDate); + expect(component.context.filterRawParams[component.id].end).toEqual(expectedToDate); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); + }); }); 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 66cb3f1098b..38910c9fc28 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 @@ -15,17 +15,18 @@ * limitations under the License. */ -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ADF_DATE_FORMATS, ADF_DATETIME_FORMATS, AdfDateFnsAdapter, AdfDateTimeFnsAdapter, DateFnsUtils } from '@alfresco/adf-core'; import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; import { LiveErrorStateMatcher } from '../../forms/live-error-state-matcher'; -import { Subject } from 'rxjs'; +import { ReplaySubject, Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { DatetimeAdapter, MAT_DATETIME_FORMATS, MatDatetimepickerInputEvent, MatDatetimepickerModule } from '@mat-datetimepicker/core'; import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; -import { isValid, isBefore, startOfMinute, endOfMinute } from 'date-fns'; +import { isValid, isBefore, startOfMinute, endOfMinute, parseISO } from 'date-fns'; import { CommonModule } from '@angular/common'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -58,7 +59,7 @@ export const DEFAULT_DATETIME_FORMAT: string = 'dd/MM/yyyy HH:mm'; encapsulation: ViewEncapsulation.None, host: { class: 'adf-search-date-range' } }) -export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { +export class SearchDatetimeRangeComponent implements SearchWidget, OnInit, OnDestroy { from: FormControl; to: FormControl; @@ -74,7 +75,9 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { isActive = false; startValue: any; enableChangeUpdate: boolean; - displayValue$: Subject = new Subject(); + displayValue$ = new ReplaySubject(1); + + private readonly destroy$ = new Subject(); constructor(private dateAdapter: DateAdapter, private dateTimeAdapter: DatetimeAdapter) {} @@ -133,9 +136,32 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { this.setFromMaxDatetime(); this.enableChangeUpdate = this.settings?.allowUpdateOnChange ?? true; + this.context.populateFilters + .asObservable() + .pipe( + map((filtersQueries) => filtersQueries[this.id]), + takeUntil(this.destroy$) + ) + .subscribe((filterQuery) => { + if (filterQuery) { + const start = parseISO(filterQuery.start); + const end = parseISO(filterQuery.end); + this.form.patchValue({ from: start, to: end }); + this.form.markAsDirty(); + this.apply({ from: start, to: end }, true, false); + } else { + this.reset(); + } + this.context.filterLoaded.next(); + }); } - apply(model: Partial<{ from: Date; to: Date }>, isValidValue: boolean) { + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + apply(model: Partial<{ from: Date; to: Date }>, isValidValue: boolean, updateContext = true) { if (isValidValue && this.id && this.context && this.settings && this.settings.field) { this.isActive = true; @@ -143,8 +169,14 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { const end = DateFnsUtils.utcToLocal(endOfMinute(model.to)).toISOString(); this.context.queryFragments[this.id] = `${this.settings.field}:['${start}' TO '${end}']`; + const filterParam = this.context.filterRawParams[this.id] ?? {}; + this.context.filterRawParams[this.id] = filterParam; + filterParam.start = start; + filterParam.end = end; this.updateDisplayValue(); - this.context.update(); + if (updateContext) { + this.context.update(); + } } } @@ -197,6 +229,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { }); if (this.id && this.context) { this.context.queryFragments[this.id] = ''; + this.context.filterRawParams[this.id] = undefined; } if (this.id && this.context && this.enableChangeUpdate) { diff --git a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html index 411d33208e9..91c488011cb 100644 --- a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html @@ -1,5 +1,6 @@ { tagService = TestBed.inject(TagService); component.id = 'test-id'; component.context = { - queryFragments: {}, - update: () => EMPTY + queryFragments: { + createdDatetimeRange: '' + }, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy('update') } as any; component.settings = { field: 'test', @@ -109,7 +113,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => { component.setValue([{ value: 'option1' }, { value: 'option2' }]); fixture.detectChanges(); expect(component.selectedOptions).toEqual([{ value: 'option1' }, { value: 'option2' }]); - spyOn(component.context, 'update'); + expect(component.context.filterRawParams[component.id]).toEqual([{ value: 'option1' }, { value: 'option2' }]); spyOn(component.displayValue$, 'next'); const clearBtn: HTMLButtonElement = fixture.debugElement.query( By.css('[data-automation-id="adf-search-chip-autocomplete-btn-clear"]') @@ -120,10 +124,10 @@ describe('SearchFilterAutocompleteChipsComponent', () => { expect(component.context.update).toHaveBeenCalled(); expect(component.selectedOptions).toEqual([]); expect(component.displayValue$.next).toHaveBeenCalledWith(''); + expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); it('should correctly compose the search query', () => { - spyOn(component.context, 'update'); component.selectedOptions = [{ value: 'option2' }, { value: 'option1' }]; const applyBtn: HTMLButtonElement = fixture.debugElement.query( By.css('[data-automation-id="adf-search-chip-autocomplete-btn-apply"]') @@ -133,11 +137,27 @@ describe('SearchFilterAutocompleteChipsComponent', () => { expect(component.context.update).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('test:"option2" OR test:"option1"'); + expect(component.context.filterRawParams[component.id]).toEqual([{ value: 'option2' }, { value: 'option1' }]); component.settings.field = AutocompleteField.CATEGORIES; component.selectedOptions = [{ id: 'test-id', value: 'test' }]; applyBtn.click(); fixture.detectChanges(); expect(component.context.queryFragments[component.id]).toBe('cm:categories:"workspace://SpacesStore/test-id"'); + expect(component.context.filterRawParams[component.id]).toEqual([{ id: 'test-id', value: 'test' }]); + }); + + it('should populate filter state when populate filters event has been observed', () => { + component.context.filterLoaded = new ReplaySubject(1); + spyOn(component.context.filterLoaded, 'next').and.stub(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + component.context.populateFilters.next({ 'test-id': [{ value: 'option2' }, { value: 'option1' }] }); + fixture.detectChanges(); + + expect(component.displayValue$.next).toHaveBeenCalledWith('option2, option1'); + expect(component.context.filterRawParams[component.id]).toEqual([{ value: 'option2' }, { value: 'option1' }]); + expect(component.selectedOptions).toEqual([{ value: 'option2' }, { value: 'option1' }]); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); }); }); 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 ae15fc75f05..6fd4f72a710 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 @@ -15,8 +15,9 @@ * limitations under the License. */ -import { Component, ViewEncapsulation, OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { Component, ViewEncapsulation, OnInit, OnDestroy } from '@angular/core'; +import { BehaviorSubject, Observable, ReplaySubject, Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; @@ -36,13 +37,13 @@ import { MatButtonModule } from '@angular/material/button'; templateUrl: './search-filter-autocomplete-chips.component.html', encapsulation: ViewEncapsulation.None }) -export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnInit { +export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnInit, OnDestroy { id: string; settings?: SearchWidgetSettings; context?: SearchQueryBuilderService; options: SearchFilterList; startValue: AutocompleteOption[] = []; - displayValue$ = new Subject(); + displayValue$ = new ReplaySubject(1); selectedOptions: AutocompleteOption[] = []; enableChangeUpdate: boolean; @@ -50,6 +51,7 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI reset$: Observable = this.resetSubject$.asObservable(); private autocompleteOptionsSubject$ = new BehaviorSubject([]); autocompleteOptions$: Observable = this.autocompleteOptionsSubject$.asObservable(); + private readonly destroy$ = new Subject(); constructor(private tagService: TagService, private categoryService: CategoryService) { this.options = new SearchFilterList(); @@ -58,17 +60,38 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI ngOnInit() { if (this.settings) { this.setOptions(); - if (this.startValue) { + if (this.startValue?.length > 0) { this.setValue(this.startValue); } this.enableChangeUpdate = this.settings.allowUpdateOnChange ?? true; } + this.context.populateFilters + .asObservable() + .pipe( + map((filterQueries) => filterQueries[this.id]), + takeUntil(this.destroy$) + ) + .subscribe((filterQuery) => { + if (filterQuery) { + this.selectedOptions = filterQuery; + this.updateQuery(false); + } else if (!filterQuery && this.selectedOptions.length) { + this.reset(false); + } + this.context.filterLoaded.next(); + }); } - reset() { + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + reset(updateContext = true) { this.selectedOptions = []; + this.context.filterRawParams[this.id] = undefined; this.resetSubject$.next(); - this.updateQuery(); + this.updateQuery(updateContext); } submitValues() { @@ -107,7 +130,8 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI return option1.id ? option1.id.toUpperCase() === option2.id.toUpperCase() : option1.value.toUpperCase() === option2.value.toUpperCase(); } - private updateQuery() { + private updateQuery(updateContext = true) { + this.context.filterRawParams[this.id] = this.selectedOptions.length > 0 ? this.selectedOptions : undefined; this.displayValue$.next(this.selectedOptions.map((option) => option.value).join(', ')); if (this.context && this.settings && this.settings.field) { let queryFragments; @@ -117,7 +141,9 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI queryFragments = this.selectedOptions.map((val) => val.query ?? `${this.settings.field}:"${val.value}"`); } this.context.queryFragments[this.id] = queryFragments.join(' OR '); - this.context.update(); + if (updateContext) { + this.context.update(); + } } } diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.ts index 66dd45da188..e049b300100 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Component, ElementRef, Input, ViewChild, ViewEncapsulation } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, ViewChild, ViewEncapsulation } from '@angular/core'; import { SearchCategory } from '../../../models/search-category.interface'; import { ConfigurableFocusTrap, ConfigurableFocusTrapFactory } from '@angular/cdk/a11y'; import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; @@ -26,6 +26,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { MatIconModule } from '@angular/material/icon'; import { SearchFilterMenuCardComponent } from '../search-filter-menu-card/search-filter-menu-card.component'; import { MatButtonModule } from '@angular/material/button'; +import { first } from 'rxjs/operators'; @Component({ selector: 'adf-search-widget-chip', @@ -50,7 +51,7 @@ import { MatButtonModule } from '@angular/material/button'; ], encapsulation: ViewEncapsulation.None }) -export class SearchWidgetChipComponent { +export class SearchWidgetChipComponent implements AfterViewInit { @Input() category: SearchCategory; @@ -66,7 +67,16 @@ export class SearchWidgetChipComponent { focusTrap: ConfigurableFocusTrap; chipIcon = 'keyboard_arrow_down'; - constructor(private focusTrapFactory: ConfigurableFocusTrapFactory) {} + constructor(private readonly cd: ChangeDetectorRef, private readonly focusTrapFactory: ConfigurableFocusTrapFactory) {} + + ngAfterViewInit(): void { + this.widgetContainerComponent + ?.getDisplayValue() + .pipe(first()) + .subscribe(() => { + this.cd.detectChanges(); + }); + } onMenuOpen() { if (this.menuContainer && !this.focusTrap) { 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 6c79cc66988..9c03587f9c7 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 @@ -19,6 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ContentTestingModule } from '../../../testing/content.testing.module'; import { LogicalSearchCondition, LogicalSearchFields, SearchLogicalFilterComponent } from './search-logical-filter.component'; +import { ReplaySubject } from 'rxjs'; describe('SearchLogicalFilterComponent', () => { let component: SearchLogicalFilterComponent; @@ -36,7 +37,9 @@ describe('SearchLogicalFilterComponent', () => { queryFragments: { logic: '' }, - update: () => {} + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy('update') } as any; component.settings = { field: 'field1,field2', allowUpdateOnChange: true, hideDefaultAction: false }; fixture.detectChanges(); @@ -131,51 +134,50 @@ describe('SearchLogicalFilterComponent', () => { const searchCondition: LogicalSearchCondition = { matchAll: 'test1', matchAny: 'test2', exclude: 'test3', matchExact: 'test4' }; component.setValue(searchCondition); fixture.detectChanges(); - spyOn(component.context, 'update'); spyOn(component.displayValue$, 'next'); component.reset(); expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.update).toHaveBeenCalled(); expect(component.getCurrentValue()).toEqual({ matchAll: '', matchAny: '', exclude: '', matchExact: '' }); expect(component.displayValue$.next).toHaveBeenCalledWith(''); + expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); it('should form correct query from match all field', () => { - spyOn(component.context, 'update'); enterNewPhrase(' test1 test2 ', 0); component.submitValues(); expect(component.context.update).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()); }); it('should form correct query from match any field', () => { - spyOn(component.context, 'update'); enterNewPhrase(' test3 test4', 1); component.submitValues(); expect(component.context.update).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()); }); it('should form correct query from exclude field', () => { - spyOn(component.context, 'update'); enterNewPhrase('test5 test6 ', 2); component.submitValues(); expect(component.context.update).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe( '((NOT field1:"test5" AND NOT field1:"test6") AND (NOT field2:"test5" AND NOT field2:"test6"))' ); + expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); it('should form correct query from match exact field and trim it', () => { - spyOn(component.context, 'update'); enterNewPhrase(' test7 test8 ', 3); component.submitValues(); expect(component.context.update).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('((field1:"test7 test8") OR (field2:"test7 test8"))'); + expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); it('should form correct joined query from all fields', () => { - spyOn(component.context, 'update'); enterNewPhrase('test1', 0); enterNewPhrase('test2', 1); enterNewPhrase('test3', 2); @@ -187,5 +189,20 @@ describe('SearchLogicalFilterComponent', () => { const subQuery4 = '((field1:"test4") OR (field2:"test4"))'; expect(component.context.update).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe(`${subQuery1} AND ${subQuery2} AND ${subQuery4} AND ${subQuery3}`); + expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); + }); + + it('should populate filter state when populate filters event has been observed', () => { + component.context.filterLoaded = new ReplaySubject(1); + spyOn(component.context.filterLoaded, 'next').and.stub(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + component.context.populateFilters.next({ logic: { matchAll: 'test', matchAny: 'test2', matchExact: '', exclude: '' } }); + fixture.detectChanges(); + + expect(component.displayValue$.next).toHaveBeenCalledWith(' SEARCH.LOGICAL_SEARCH.MATCH_ALL: test SEARCH.LOGICAL_SEARCH.MATCH_ANY: test2'); + expect(component.context.filterRawParams[component.id]).toEqual({ matchAll: 'test', matchAny: 'test2', matchExact: '', exclude: '' }); + expect(component.searchCondition).toEqual({ matchAll: 'test', matchAny: 'test2', matchExact: '', exclude: '' }); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); }); }); 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 f935049434d..a10123dc076 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 @@ -15,11 +15,12 @@ * limitations under the License. */ -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; -import { Subject } from 'rxjs'; +import { ReplaySubject, Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { TranslationService } from '@alfresco/adf-core'; import { CommonModule } from '@angular/common'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -45,7 +46,7 @@ export interface LogicalSearchCondition extends LogicalSearchConditionEnumValued styleUrls: ['./search-logical-filter.component.scss'], encapsulation: ViewEncapsulation.None }) -export class SearchLogicalFilterComponent implements SearchWidget, OnInit { +export class SearchLogicalFilterComponent implements SearchWidget, OnInit, OnDestroy { id: string; settings?: SearchWidgetSettings; context?: SearchQueryBuilderService; @@ -53,15 +54,37 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit { searchCondition: LogicalSearchCondition; fields = Object.keys(LogicalSearchFields); LogicalSearchFields = LogicalSearchFields; - displayValue$: Subject = new Subject(); + displayValue$ = new ReplaySubject(1); + + private readonly destroy$ = new Subject(); constructor(private translationService: TranslationService) {} ngOnInit(): void { this.clearSearchInputs(); + this.context.populateFilters + .asObservable() + .pipe( + map((filtersQueries) => filtersQueries[this.id]), + takeUntil(this.destroy$) + ) + .subscribe((filterQuery) => { + if (filterQuery) { + this.searchCondition = filterQuery; + this.submitValues(false); + } else { + this.reset(false); + } + this.context.filterLoaded.next(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } - submitValues() { + submitValues(updateContext = true) { if (this.hasValidValue() && this.id && this.context && this.settings && this.settings.field) { this.updateDisplayValue(); const fields = this.settings.field.split(',').map((field) => (field += ':')); @@ -108,9 +131,11 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit { } }); this.context.queryFragments[this.id] = query; - this.context.update(); + if (updateContext) { + this.context.update(); + } } else { - this.reset(); + this.reset(updateContext); } } @@ -127,15 +152,19 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit { this.updateDisplayValue(); } - reset() { + reset(updateContext = true) { if (this.id && this.context) { this.context.queryFragments[this.id] = ''; this.clearSearchInputs(); - this.context.update(); + this.context.filterRawParams[this.id] = this.searchCondition; + if (updateContext) { + this.context.update(); + } } } private updateDisplayValue(): void { + this.context.filterRawParams[this.id] = this.searchCondition; if (this.hasValidValue()) { const displayValue = Object.keys(this.searchCondition).reduce((acc, key) => { const fieldIndex = Object.values(LogicalSearchFields).indexOf(key as LogicalSearchFields); 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 06fce1f9af1..2cd5f429fff 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 @@ -15,15 +15,31 @@ * limitations under the License. */ +import { ReplaySubject } from 'rxjs'; import { SearchNumberRangeComponent } from './search-number-range.component'; import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ContentTestingModule } from '../../../testing/content.testing.module'; describe('SearchNumberRangeComponent', () => { - let component: SearchNumberRangeComponent; + let fixture: ComponentFixture; beforeEach(() => { - component = new SearchNumberRangeComponent(); + TestBed.configureTestingModule({ + imports: [ContentTestingModule, SearchNumberRangeComponent] + }); + fixture = TestBed.createComponent(SearchNumberRangeComponent); + component = fixture.componentInstance; + component.id = 'contentSize'; + component.context = { + queryFragments: { + contentSize: '' + }, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy('update') + } as any; }); it('should setup form elements on init', () => { @@ -44,46 +60,32 @@ describe('SearchNumberRangeComponent', () => { }); it('should update query builder on reset', () => { - const context: any = { - queryFragments: { - contentSize: 'query' - }, - update: () => {} - }; - - component.id = 'contentSize'; - component.context = context; - - spyOn(context, 'update').and.stub(); - + component.context.queryFragments[component.id] = 'query'; component.ngOnInit(); component.reset(); - expect(context.queryFragments.contentSize).toEqual(''); - expect(context.update).toHaveBeenCalled(); + expect(component.context.queryFragments.contentSize).toEqual(''); + expect(component.context.update).toHaveBeenCalled(); + expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); it('should update query builder on value changes', () => { - const context: any = { - queryFragments: {}, - update: () => {} - }; - - component.id = 'contentSize'; - component.context = context; component.settings = { field: 'cm:content.size' }; - spyOn(context, 'update').and.stub(); - component.ngOnInit(); - component.apply({ - from: '10', - to: '20' - }, true); + component.apply( + { + from: '10', + to: '20' + }, + true + ); const expectedQuery = 'cm:content.size:[10 TO 20]'; - expect(context.queryFragments[component.id]).toEqual(expectedQuery); - expect(context.update).toHaveBeenCalled(); + expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); + expect(component.context.update).toHaveBeenCalled(); + expect(component.context.filterRawParams[component.id].from).toEqual('10'); + expect(component.context.filterRawParams[component.id].to).toEqual('20'); }); it('should fetch format from the settings', () => { @@ -108,31 +110,27 @@ describe('SearchNumberRangeComponent', () => { }); it('should format value based on the current pattern', () => { - const context: any = { - queryFragments: {}, - update: () => {} - }; - - component.id = 'range1'; component.settings = { field: 'cm:content.size', format: '<{FROM} TO {TO}>' }; - component.context = context; component.ngOnInit(); component.apply({ from: '0', to: '100' }, true); - expect(context.queryFragments['range1']).toEqual('cm:content.size:<0 TO 100>'); + expect(component.context.queryFragments[component.id]).toEqual('cm:content.size:<0 TO 100>'); }); it('should return true if TO value is bigger than FROM value', () => { component.ngOnInit(); component.from = new UntypedFormControl('10'); component.to = new UntypedFormControl('20'); - component.form = new UntypedFormGroup({ - from: component.from, - to: component.to - }, component.formValidator); + component.form = new UntypedFormGroup( + { + from: component.from, + to: component.to + }, + component.formValidator + ); expect(component.formValidator).toBeTruthy(); }); @@ -166,4 +164,21 @@ describe('SearchNumberRangeComponent', () => { component.from = new UntypedFormControl(-100, component.validators); expect(component.from.hasError('min')).toBe(true); }); + + it('should populate filter state when populate filters event has been observed', () => { + component.settings = { + field: 'cm:content.size' + }; + component.context.filterLoaded = new ReplaySubject(1); + spyOn(component.context.filterLoaded, 'next').and.stub(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + component.context.populateFilters.next({ contentSize: { from: '10', to: '100' } }); + fixture.detectChanges(); + + expect(component.displayValue$.next).toHaveBeenCalledWith('10 - 100 '); + expect(component.context.filterRawParams[component.id]).toEqual({ from: '10', to: '100' }); + expect(component.form.value).toEqual({ from: '10', to: '100' }); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); + }); }); 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 3299794a72f..bcb7e67fea4 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 @@ -21,7 +21,8 @@ import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; import { LiveErrorStateMatcher } from '../../forms/live-error-state-matcher'; -import { Subject } from 'rxjs'; +import { ReplaySubject } from 'rxjs'; +import { first, map } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -56,7 +57,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { validators: Validators; enableChangeUpdate: boolean; - displayValue$: Subject = new Subject(); + displayValue$ = new ReplaySubject(1); ngOnInit(): void { if (this.settings) { @@ -84,32 +85,54 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { this.enableChangeUpdate = this.settings?.allowUpdateOnChange ?? true; this.updateDisplayValue(); + this.context.populateFilters + .asObservable() + .pipe( + map((filtersQueries) => filtersQueries[this.id]), + first() + ) + .subscribe((filterQuery) => { + if (filterQuery) { + this.form.patchValue({ from: filterQuery.from, to: filterQuery.to }); + this.form.markAsDirty(); + this.apply({ from: filterQuery.from, to: filterQuery.to }, true, false); + } else { + this.reset(false); + } + this.context.filterLoaded.next(); + }); } formValidator(formGroup: UntypedFormGroup) { return parseInt(formGroup.get('from').value, 10) < parseInt(formGroup.get('to').value, 10) ? null : { mismatch: true }; } - apply(model: { from: string; to: string }, isValid: boolean) { + apply(model: { from: string; to: string }, isValid: boolean, updateContext = true) { if (isValid && this.id && this.context && this.field) { this.updateDisplayValue(); this.isActive = true; - const map = new Map(); - map.set('FROM', model.from); - map.set('TO', model.to); + const destinationObject = new Map(); + destinationObject.set('FROM', model.from); + destinationObject.set('TO', model.to); - const value = this.formatString(this.format, map); + const value = this.formatString(this.format, destinationObject); this.context.queryFragments[this.id] = `${this.field}:${value}`; - this.context.update(); + const filterParam = this.context.filterRawParams[this.id] ?? {}; + this.context.filterRawParams[this.id] = filterParam; + filterParam.from = model.from; + filterParam.to = model.to; + if (updateContext) { + this.context.update(); + } } } - private formatString(str: string, map: Map): string { + private formatString(str: string, destinationObject: Map): string { let result = str; - map.forEach((value, key) => { + destinationObject.forEach((value, key) => { const expr = new RegExp('{' + key + '}', 'gm'); result = result.replace(expr, value); }); @@ -143,7 +166,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { this.updateDisplayValue(); } - clear() { + clear(updateContext = true) { this.isActive = false; this.form.reset({ @@ -153,16 +176,17 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { if (this.id && this.context) { this.context.queryFragments[this.id] = ''; + this.context.filterRawParams[this.id] = undefined; this.updateDisplayValue(); - if (this.enableChangeUpdate) { + if (this.enableChangeUpdate && updateContext) { this.context.update(); } } } - reset() { + reset(updateContext = true) { this.clear(); - if (this.id && this.context) { + if (this.id && this.context && updateContext) { this.context.update(); } } diff --git a/lib/content-services/src/lib/search/components/search-panel/search-panel.component.spec.ts b/lib/content-services/src/lib/search/components/search-panel/search-panel.component.spec.ts index 1521e8cd1ce..5e1bd044917 100644 --- a/lib/content-services/src/lib/search/components/search-panel/search-panel.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-panel/search-panel.component.spec.ts @@ -15,194 +15,44 @@ * limitations under the License. */ -import { SearchCheckListComponent, SearchListOption } from '../search-check-list/search-check-list.component'; -import { SearchFilterList } from '../../models/search-filter-list.model'; import { ContentTestingModule } from '../../../testing/content.testing.module'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { sizeOptions, stepOne, stepThree } from '../../../mock'; -import { HarnessLoader, TestKey } from '@angular/cdk/testing'; -import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; +import { SearchPanelComponent } from './search-panel.component'; import { By } from '@angular/platform-browser'; +import { ContentNodeSelectorPanelService } from '../../../content-node-selector'; +import { SearchCategory } from '../../models'; -describe('SearchCheckListComponent', () => { - let loader: HarnessLoader; - let fixture: ComponentFixture; - let component: SearchCheckListComponent; +describe('SearchPanelComponent', () => { + let fixture: ComponentFixture; + let contentNodeSelectorPanelService: ContentNodeSelectorPanelService; + + const getSearchFilter = () => fixture.debugElement.query(By.css('.app-search-settings')); beforeEach(() => { TestBed.configureTestingModule({ imports: [ContentTestingModule] }); - fixture = TestBed.createComponent(SearchCheckListComponent); - component = fixture.componentInstance; + fixture = TestBed.createComponent(SearchPanelComponent); + contentNodeSelectorPanelService = TestBed.inject(ContentNodeSelectorPanelService); fixture.detectChanges(); - loader = TestbedHarnessEnvironment.loader(fixture); - }); - - it('should setup options from settings', () => { - const options: any = [ - { name: 'Folder', value: `TYPE:'cm:folder'` }, - { name: 'Document', value: `TYPE:'cm:content'` } - ]; - component.settings = { options } as any; - component.ngOnInit(); - - expect(component.options.items).toEqual(options); }); - it('should handle enter key as click on checkboxes', async () => { - component.options = new SearchFilterList([ - { name: 'Folder', value: `TYPE:'cm:folder'`, checked: false }, - { name: 'Document', value: `TYPE:'cm:content'`, checked: false } - ]); - - component.ngOnInit(); + it('should not render search filter when no custom models are available', () => { + contentNodeSelectorPanelService.customModels = []; + spyOn(contentNodeSelectorPanelService, 'convertCustomModelPropertiesToSearchCategories').and.returnValue([]); fixture.detectChanges(); - - const options = await loader.getAllHarnesses(MatCheckboxHarness); - await (await options[0].host()).sendKeys(TestKey.ENTER); - expect(await options[0].isChecked()).toBe(true); - - await (await options[0].host()).sendKeys(TestKey.ENTER); - expect(await options[0].isChecked()).toBe(false); - }); - - it('should setup operator from the settings', () => { - component.settings = { operator: 'AND' } as any; - component.ngOnInit(); - expect(component.operator).toBe('AND'); - }); - - it('should use OR operator by default', () => { - component.settings = { operator: null } as any; - component.ngOnInit(); - expect(component.operator).toBe('OR'); - }); - - it('should update query builder on checkbox change', () => { - component.options = new SearchFilterList([ - { name: 'Folder', value: `TYPE:'cm:folder'`, checked: false }, - { name: 'Document', value: `TYPE:'cm:content'`, checked: false } - ]); - - component.id = 'checklist'; - component.context = { - queryFragments: {}, - update: () => {} - } as any; - - component.ngOnInit(); - - spyOn(component.context, 'update').and.stub(); - - component.changeHandler({ checked: true } as any, component.options.items[0]); - - expect(component.context.queryFragments[component.id]).toEqual(`TYPE:'cm:folder'`); - - component.changeHandler({ checked: true } as any, component.options.items[1]); - - expect(component.context.queryFragments[component.id]).toEqual(`TYPE:'cm:folder' OR TYPE:'cm:content'`); - }); - - it('should reset selected boxes', () => { - component.options = new SearchFilterList([ - { name: 'Folder', value: `TYPE:'cm:folder'`, checked: true }, - { name: 'Document', value: `TYPE:'cm:content'`, checked: true } - ]); - - component.reset(); - - expect(component.options.items[0].checked).toBeFalsy(); - expect(component.options.items[1].checked).toBeFalsy(); - }); - - it('should update query builder on reset', () => { - component.id = 'checklist'; - component.context = { - queryFragments: { - checklist: 'query' - }, - update: () => {} - } as any; - spyOn(component.context, 'update').and.stub(); - - component.ngOnInit(); - component.options = new SearchFilterList([ - { name: 'Folder', value: `TYPE:'cm:folder'`, checked: true }, - { name: 'Document', value: `TYPE:'cm:content'`, checked: true } - ]); - - component.reset(); - - expect(component.context.update).toHaveBeenCalled(); - expect(component.context.queryFragments[component.id]).toBe(''); + expect(getSearchFilter()).toBeNull(); }); - describe('Pagination', () => { - it('should show 5 items when pageSize not defined', async () => { - component.id = 'checklist'; - component.context = { - queryFragments: { - checklist: 'query' - }, - update: () => {} - } as any; - component.settings = { options: sizeOptions } as any; - - component.ngOnInit(); - fixture.detectChanges(); - - const options = await loader.getAllHarnesses(MatCheckboxHarness); - expect(options.length).toEqual(5); - - const labels = await Promise.all(Array.from(options).map(async (element) => element.getLabelText())); - expect(labels).toEqual(stepOne); - }); - - it('should show all items when pageSize is high', async () => { - component.id = 'checklist'; - component.context = { - queryFragments: { - checklist: 'query' - }, - update: () => {} - } as any; - component.settings = { pageSize: 15, options: sizeOptions } as any; - component.ngOnInit(); - fixture.detectChanges(); - - const options = await loader.getAllHarnesses(MatCheckboxHarness); - expect(options.length).toEqual(13); - - const labels = await Promise.all(Array.from(options).map(async (element) => element.getLabelText())); - expect(labels).toEqual(stepThree); - }); - }); - - it('should able to check/reset the checkbox', async () => { - component.id = 'checklist'; - component.context = { - queryFragments: { - checklist: 'query' - }, - update: () => {} - } as any; - component.settings = { options: sizeOptions } as any; - spyOn(component, 'submitValues').and.stub(); - component.ngOnInit(); - fixture.detectChanges(); - - const checkbox = await loader.getHarness(MatCheckboxHarness); - await checkbox.check(); - - expect(component.submitValues).toHaveBeenCalled(); - - const clearAllElement = fixture.debugElement.query(By.css('button[title="SEARCH.FILTER.ACTIONS.CLEAR-ALL"]')); - clearAllElement.triggerEventHandler('click', {}); + it('should render search filter when some custom models are available', () => { + const categoriesMock: SearchCategory[] = [ + { id: 'model1', name: 'model1', enabled: true, expanded: false, component: { selector: 'test', settings: { field: 'test' } } }, + { id: 'model2', name: 'model2', enabled: true, expanded: false, component: { selector: 'test2', settings: { field: 'test2' } } } + ]; + contentNodeSelectorPanelService.customModels = ['model1', 'model2']; + spyOn(contentNodeSelectorPanelService, 'convertCustomModelPropertiesToSearchCategories').and.returnValue(categoriesMock); fixture.detectChanges(); - - expect(await checkbox.isChecked()).toBe(false); + expect(getSearchFilter()).toBeDefined(); }); }); diff --git a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.html b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.html index 37e8ce454f9..bd1995160c8 100644 --- a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.html +++ b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.html @@ -46,6 +46,7 @@

{{ 'SEARCH.SEARCH_PROPERTIES.FILE_TYPE' | translate }}

{ let component: SearchPropertiesComponent; @@ -66,6 +66,15 @@ describe('SearchPropertiesComponent', () => { fixture = TestBed.createComponent(SearchPropertiesComponent); component = fixture.componentInstance; + component.id = 'properties'; + component.context = { + queryFragments: { + properties: '' + }, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy('update') + } as any; }); describe('File size', () => { @@ -187,14 +196,11 @@ describe('SearchPropertiesComponent', () => { const nameField = 'cm:name'; beforeEach(() => { - component.id = 'properties'; component.settings = { field: `${sizeField},${nameField}` }; - component.context = TestBed.inject(SearchQueryBuilderService); fixture.detectChanges(); spyOn(component.displayValue$, 'next'); - spyOn(component.context, 'update'); }); it('should not search when settings is not set', () => { @@ -203,7 +209,6 @@ describe('SearchPropertiesComponent', () => { component.submitValues(); expect(component.displayValue$.next).not.toHaveBeenCalled(); - expect(component.context.queryFragments[component.id]).toBeUndefined(); expect(component.context.update).not.toHaveBeenCalled(); }); @@ -219,6 +224,10 @@ describe('SearchPropertiesComponent', () => { component.submitValues(); expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.filterRawParams[component.id]).toEqual({ + fileExtensions: undefined, + fileSizeCondition: { fileSize: null, fileSizeOperator: FileSizeOperator.AT_LEAST, fileSizeUnit: FileSizeUnit.KB } + }); expect(component.context.update).toHaveBeenCalled(); }); @@ -230,6 +239,14 @@ describe('SearchPropertiesComponent', () => { 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.KB' ); expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[328704 TO MAX]`); + expect(component.context.filterRawParams[component.id]).toEqual({ + fileExtensions: undefined, + fileSizeCondition: { + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST', + fileSize: 321, + fileSizeUnit: FileSizeUnit.KB + } + }); expect(component.context.update).toHaveBeenCalled(); }); @@ -247,6 +264,14 @@ describe('SearchPropertiesComponent', () => { 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.MB' ); expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[0 TO 336592896]`); + expect(component.context.filterRawParams[component.id]).toEqual({ + fileExtensions: undefined, + fileSizeCondition: { + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST', + fileSize: 321, + fileSizeUnit: FileSizeUnit.MB + } + }); expect(component.context.update).toHaveBeenCalled(); }); @@ -264,6 +289,14 @@ describe('SearchPropertiesComponent', () => { 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.EXACTLY 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.GB' ); expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[344671125504 TO 344671125504]`); + expect(component.context.filterRawParams[component.id]).toEqual({ + fileExtensions: undefined, + fileSizeCondition: { + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.EXACTLY', + fileSize: 321, + fileSizeUnit: FileSizeUnit.GB + } + }); expect(component.context.update).toHaveBeenCalled(); }); @@ -274,6 +307,14 @@ describe('SearchPropertiesComponent', () => { component.submitValues(); expect(component.displayValue$.next).toHaveBeenCalledWith('pdf'); expect(component.context.queryFragments[component.id]).toBe(`${nameField}:("*.${extension.value}")`); + expect(component.context.filterRawParams[component.id]).toEqual({ + fileExtensions: ['pdf'], + fileSizeCondition: { + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST', + fileSize: null, + fileSizeUnit: FileSizeUnit.KB + } + }); expect(component.context.update).toHaveBeenCalled(); }); @@ -283,6 +324,14 @@ describe('SearchPropertiesComponent', () => { component.submitValues(); expect(component.displayValue$.next).toHaveBeenCalledWith('pdf, txt'); expect(component.context.queryFragments[component.id]).toBe(`${nameField}:("*.pdf" OR "*.txt")`); + expect(component.context.filterRawParams[component.id]).toEqual({ + fileExtensions: ['pdf', 'txt'], + fileSizeCondition: { + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST', + fileSize: null, + fileSizeUnit: FileSizeUnit.KB + } + }); expect(component.context.update).toHaveBeenCalled(); }); @@ -295,6 +344,14 @@ describe('SearchPropertiesComponent', () => { 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.KB, pdf, txt' ); expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[328704 TO MAX] AND ${nameField}:("*.pdf" OR "*.txt")`); + expect(component.context.filterRawParams[component.id]).toEqual({ + fileExtensions: ['pdf', 'txt'], + fileSizeCondition: { + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST', + fileSize: 321, + fileSizeUnit: FileSizeUnit.KB + } + }); expect(component.context.update).toHaveBeenCalled(); }); }); @@ -377,14 +434,12 @@ describe('SearchPropertiesComponent', () => { }); it('should clear the queryFragments for the component id and call update', () => { - component.context = TestBed.inject(SearchQueryBuilderService); - component.id = 'test-id'; component.context.queryFragments[component.id] = 'test-query'; fixture.detectChanges(); - spyOn(component.context, 'update'); component.reset(); expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.filterRawParams[component.id]).toBeUndefined(); expect(component.context.update).toHaveBeenCalled(); }); }); @@ -411,20 +466,25 @@ describe('SearchPropertiesComponent', () => { it('should search based on passed value', () => { const sizeField = 'content.size'; const nameField = 'cm:name'; - component.id = 'properties'; component.settings = { field: `${sizeField},${nameField}` }; - component.context = TestBed.inject(SearchQueryBuilderService); component.ngOnInit(); spyOn(component.displayValue$, 'next'); - spyOn(component.context, 'update'); component.setValue(searchProperties); expect(component.displayValue$.next).toHaveBeenCalledWith( 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.MB, pdf, txt' ); expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[0 TO 336592896] AND ${nameField}:("*.pdf" OR "*.txt")`); + expect(component.context.filterRawParams[component.id]).toEqual({ + fileExtensions: ['pdf', 'txt'], + fileSizeCondition: { + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST', + fileSize: 321, + fileSizeUnit: FileSizeUnit.MB + } + }); expect(component.context.update).toHaveBeenCalled(); }); }); @@ -470,4 +530,37 @@ describe('SearchPropertiesComponent', () => { ).toBeTrue(); }); }); + + it('should populate filter state when populate filters event has been observed', () => { + component.settings = { + field: 'field' + }; + component.context.filterLoaded = new ReplaySubject(1); + spyOn(component.context.filterLoaded, 'next').and.stub(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + component.context.populateFilters.next({ + properties: { + fileExtensions: ['pdf', 'txt'], + fileSizeCondition: { + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST', + fileSize: 321, + fileSizeUnit: FileSizeUnit.MB + } + } + }); + fixture.detectChanges(); + + expect(component.displayValue$.next).toHaveBeenCalledWith( + 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.MB, pdf, txt' + ); + expect(component.selectedExtensions).toEqual([{ value: 'pdf' }, { value: 'txt' }]); + expect(component.preselectedOptions).toEqual([{ value: 'pdf' }, { value: 'txt' }]); + expect(component.form.value).toEqual({ + fileSizeOperator: 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST', + fileSize: 321, + fileSizeUnit: FileSizeUnit.MB + }); + expect(component.context.filterLoaded.next).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 2ea947d3d70..ee34979d412 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 @@ -15,12 +15,12 @@ * limitations under the License. */ -import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { AfterViewChecked, Component, ElementRef, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { FileSizeCondition } from './file-size-condition'; import { FileSizeOperator } from './file-size-operator.enum'; import { FileSizeUnit } from './file-size-unit.enum'; -import { Subject } from 'rxjs'; +import { ReplaySubject, Subject } from 'rxjs'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; import { SearchProperties } from './search-properties'; @@ -31,6 +31,7 @@ import { CommonModule } from '@angular/common'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { SearchChipAutocompleteInputComponent } from '../search-chip-autocomplete-input'; +import { map, takeUntil } from 'rxjs/operators'; @Component({ selector: 'adf-search-properties', @@ -40,13 +41,14 @@ import { SearchChipAutocompleteInputComponent } from '../search-chip-autocomplet styleUrls: ['./search-properties.component.scss'], encapsulation: ViewEncapsulation.None }) -export class SearchPropertiesComponent implements OnInit, AfterViewChecked, SearchWidget { +export class SearchPropertiesComponent implements OnInit, AfterViewChecked, OnDestroy, SearchWidget { id: string; settings?: SearchWidgetSettings; context?: SearchQueryBuilderService; startValue: SearchProperties; - displayValue$ = new Subject(); + displayValue$ = new ReplaySubject(1); autocompleteOptions: AutocompleteOption[] = []; + preselectedOptions: AutocompleteOption[] = []; private _form = this.formBuilder.nonNullable.group({ fileSizeOperator: FileSizeOperator.AT_LEAST, @@ -85,10 +87,16 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear return this._reset$; } + get selectedExtensions(): AutocompleteOption[] { + return this.parseToAutocompleteOptions(this._selectedExtensions); + } + set selectedExtensions(extensions: AutocompleteOption[]) { this._selectedExtensions = this.parseFromAutocompleteOptions(extensions); } + private readonly destroy$ = new Subject(); + constructor(private formBuilder: FormBuilder, private translateService: TranslateService) {} ngOnInit() { @@ -102,6 +110,27 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear if (this.startValue) { this.setValue(this.startValue); } + this.context.populateFilters + .asObservable() + .pipe( + map((filtersQueries) => filtersQueries[this.id]), + takeUntil(this.destroy$) + ) + .subscribe((filterQuery) => { + if (filterQuery) { + filterQuery.fileSizeCondition.fileSizeUnit = this.fileSizeUnits.find( + (fileSizeUnit) => fileSizeUnit.bytes === filterQuery.fileSizeCondition.fileSizeUnit.bytes + ); + this.form.patchValue(filterQuery.fileSizeCondition); + this.form.updateValueAndValidity(); + this._selectedExtensions = filterQuery.fileExtensions ?? []; + this.preselectedOptions = this.parseToAutocompleteOptions(this._selectedExtensions); + this.submitValues(false); + } else { + this.reset(false); + } + this.context.filterLoaded.next(); + }); } ngAfterViewChecked() { @@ -120,6 +149,11 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear } } + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + narrowDownAllowedCharacters(event: Event) { const value = (event.target as HTMLInputElement).value; if (!(event.target as HTMLInputElement).value) { @@ -160,47 +194,28 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear }); }; - reset() { + reset(updateContext = true) { this.form.reset(); if (this.id && this.context) { this.context.queryFragments[this.id] = ''; - this.context.update(); + this.context.filterRawParams[this.id] = undefined; + if (updateContext) { + this.context.update(); + } } this.reset$.next(); this.displayValue$.next(''); } - submitValues() { + submitValues(updateContext = true) { + if (this.context?.filterRawParams) { + this.context.filterRawParams[this.id] = { + fileExtensions: this._selectedExtensions, + fileSizeCondition: this.form.value + }; + } if (this.settings && this.context) { - let query = ''; - let displayedValue = ''; - if (this.form.value.fileSize !== undefined && this.form.value.fileSize !== null) { - displayedValue = `${this.translateService.instant(this.form.value.fileSizeOperator)} ${ - this.form.value.fileSize - } ${this.translateService.instant(this.form.value.fileSizeUnit.abbreviation)}`; - const size = this.form.value.fileSize * this.form.value.fileSizeUnit.bytes; - switch (this.form.value.fileSizeOperator) { - case FileSizeOperator.AT_MOST: - query = `${this.sizeField}:[0 TO ${size}]`; - break; - case FileSizeOperator.AT_LEAST: - query = `${this.sizeField}:[${size} TO MAX]`; - break; - default: - query = `${this.sizeField}:[${size} TO ${size}]`; - } - } - if (this._selectedExtensions?.length) { - if (query) { - query += ' AND '; - displayedValue += ', '; - } - query += `${this.nameField}:("*.${this._selectedExtensions.join('" OR "*.')}")`; - displayedValue += this._selectedExtensions.join(', '); - } - this.displayValue$.next(displayedValue); - this.context.queryFragments[this.id] = query; - this.context.update(); + this.updateSettingsAndContext(updateContext); } } @@ -217,10 +232,44 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear setValue(searchProperties: SearchProperties) { this.form.patchValue(searchProperties.fileSizeCondition); - this.selectedExtensions = this.parseToAutocompleteOptions(searchProperties.fileExtensions); + this.selectedExtensions = this.parseToAutocompleteOptions(searchProperties.fileExtensions ?? []); this.submitValues(); } + private updateSettingsAndContext(updateContext = true): void { + let query = ''; + let displayedValue = ''; + if (this.form.value.fileSize !== undefined && this.form.value.fileSize !== null) { + displayedValue = `${this.translateService.instant(this.form.value.fileSizeOperator)} ${ + this.form.value.fileSize + } ${this.translateService.instant(this.form.value.fileSizeUnit.abbreviation)}`; + const size = this.form.value.fileSize * this.form.value.fileSizeUnit.bytes; + switch (this.form.value.fileSizeOperator) { + case FileSizeOperator.AT_MOST: + query = `${this.sizeField}:[0 TO ${size}]`; + break; + case FileSizeOperator.AT_LEAST: + query = `${this.sizeField}:[${size} TO MAX]`; + break; + default: + query = `${this.sizeField}:[${size} TO ${size}]`; + } + } + if (this._selectedExtensions?.length) { + if (query) { + query += ' AND '; + displayedValue += ', '; + } + query += `${this.nameField}:("*.${this._selectedExtensions.join('" OR "*.')}")`; + displayedValue += this._selectedExtensions.join(', '); + } + this.displayValue$.next(displayedValue); + this.context.queryFragments[this.id] = query; + if (updateContext) { + this.context.update(); + } + } + private parseToAutocompleteOptions(array: string[]): AutocompleteOption[] { return array.map((value) => ({ value })); } 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 6626386a0c5..758d78ced85 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 @@ -22,6 +22,7 @@ import { ContentTestingModule } from '../../../testing/content.testing.module'; import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatRadioButtonHarness, MatRadioGroupHarness } from '@angular/material/radio/testing'; +import { ReplaySubject } from 'rxjs'; describe('SearchRadioComponent', () => { let loader: HarnessLoader; @@ -36,20 +37,20 @@ describe('SearchRadioComponent', () => { component = fixture.componentInstance; loader = TestbedHarnessEnvironment.loader(fixture); + component.id = 'radio'; + component.context = { + queryFragments: { + radio: 'query' + }, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy('update') + } as any; + component.settings = { options: sizeOptions } as any; }); describe('Pagination', () => { it('should show 5 items when pageSize not defined', async () => { - component.id = 'radio'; - component.context = { - queryFragments: { - radio: 'query' - }, - update: () => {} - } as any; - component.settings = { options: sizeOptions } as any; - - component.ngOnInit(); fixture.detectChanges(); const options = await loader.getAllHarnesses(MatRadioButtonHarness); @@ -60,15 +61,7 @@ describe('SearchRadioComponent', () => { }); it('should show all items when pageSize is high', async () => { - component.id = 'radio'; - component.context = { - queryFragments: { - radio: 'query' - }, - update: () => {} - } as any; - component.settings = { pageSize: 15, options: sizeOptions } as any; - component.ngOnInit(); + component.settings['pageSize'] = 15; fixture.detectChanges(); const options = await loader.getAllHarnesses(MatRadioButtonHarness); @@ -80,18 +73,40 @@ describe('SearchRadioComponent', () => { }); it('should able to check the radio button', async () => { - component.id = 'radio'; - component.context = { - queryFragments: { - radio: 'query' - }, - update: () => {} - } as any; - component.settings = { options: sizeOptions } as any; + const group = await loader.getHarness(MatRadioGroupHarness); + await group.checkRadioButton({ selector: `[data-automation-id="search-radio-${sizeOptions[1].name}"]` }); + expect(component.context.queryFragments[component.id]).toBe(sizeOptions[1].value); + expect(component.context.filterRawParams[component.id]).toBe(sizeOptions[1].value); + }); + + it('should reset to initial value ', async () => { const group = await loader.getHarness(MatRadioGroupHarness); - await group.checkRadioButton({ selector: `[data-automation-id="search-radio-${sizeOptions[0].name}"]` }); + await group.checkRadioButton({ selector: `[data-automation-id="search-radio-${sizeOptions[2].name}"]` }); + + expect(component.context.queryFragments[component.id]).toBe(sizeOptions[2].value); + expect(component.context.filterRawParams[component.id]).toBe(sizeOptions[2].value); + + component.reset(); + fixture.detectChanges(); expect(component.context.queryFragments[component.id]).toBe(sizeOptions[0].value); + expect(component.context.filterRawParams[component.id]).toBe(sizeOptions[0].value); + }); + + 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(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + component.context.populateFilters.next({ radio: sizeOptions[1].value }); + fixture.detectChanges(); + const group = await loader.getHarness(MatRadioGroupHarness); + + expect(component.displayValue$.next).toHaveBeenCalledWith(sizeOptions[1].name); + expect(component.value).toEqual(sizeOptions[1].value); + expect(component.context.filterRawParams[component.id]).toBe(sizeOptions[1].value); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); + expect(await group.getCheckedValue()).toEqual(sizeOptions[1].value); }); }); 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 8f93a20798e..25e2cbde1f1 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 @@ -22,7 +22,8 @@ import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; import { SearchFilterList } from '../../models/search-filter-list.model'; -import { Subject } from 'rxjs'; +import { ReplaySubject } from 'rxjs'; +import { first } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { TranslateModule } from '@ngx-translate/core'; @@ -56,7 +57,7 @@ export class SearchRadioComponent implements SearchWidget, OnInit { isActive = false; startValue: any; enableChangeUpdate: boolean; - displayValue$: Subject = new Subject(); + displayValue$ = new ReplaySubject(1); constructor() { this.options = new SearchFilterList(); @@ -82,6 +83,18 @@ export class SearchRadioComponent implements SearchWidget, OnInit { } this.enableChangeUpdate = this.settings.allowUpdateOnChange ?? true; this.updateDisplayValue(); + this.context.populateFilters + .asObservable() + .pipe(first()) + .subscribe((filtersQueries) => { + if (filtersQueries[this.id]) { + this.value = filtersQueries[this.id]; + this.submitValues(false); + } else { + this.reset(false); + } + this.context.filterLoaded.next(); + }); } private getSelectedValue(): string { @@ -98,10 +111,12 @@ export class SearchRadioComponent implements SearchWidget, OnInit { return null; } - submitValues() { + submitValues(updateContext = true) { this.setValue(this.value); this.updateDisplayValue(); - this.context.update(); + if (updateContext) { + this.context.update(); + } } hasValidValue() { @@ -112,6 +127,7 @@ export class SearchRadioComponent implements SearchWidget, OnInit { setValue(newValue: string) { this.value = newValue; this.context.queryFragments[this.id] = newValue; + this.context.filterRawParams[this.id] = newValue; if (this.enableChangeUpdate) { this.updateDisplayValue(); this.context.update(); @@ -143,12 +159,14 @@ export class SearchRadioComponent implements SearchWidget, OnInit { } } - reset() { + reset(updateContext = true) { const initialValue = this.getSelectedValue(); if (initialValue !== null) { this.setValue(initialValue); this.updateDisplayValue(); - this.context.update(); + if (updateContext) { + this.context.update(); + } } } } 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 34914ba1a4b..6e38bfcefe8 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 @@ -18,6 +18,7 @@ import { SearchSliderComponent } from './search-slider.component'; import { ContentTestingModule } from '../../../testing/content.testing.module'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReplaySubject } from 'rxjs'; describe('SearchSliderComponent', () => { let fixture: ComponentFixture; @@ -29,101 +30,84 @@ describe('SearchSliderComponent', () => { }); fixture = TestBed.createComponent(SearchSliderComponent); component = fixture.componentInstance; - }); - - it('should setup slider from settings', () => { - const settings: any = { + component.id = 'slider'; + component.context = { + queryFragments: { + slider: '' + }, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy('update') + } as any; + component.settings = { + field: 'field1', min: 10, max: 100, step: 2, thumbLabel: true }; + }); - component.settings = settings; + it('should setup slider from settings', () => { fixture.detectChanges(); - expect(component.min).toEqual(settings.min); - expect(component.max).toEqual(settings.max); - expect(component.step).toEqual(settings.step); - expect(component.thumbLabel).toEqual(settings.thumbLabel); + expect(component.min).toEqual(10); + expect(component.max).toEqual(100); + expect(component.step).toEqual(2); + expect(component.thumbLabel).toEqual(true); }); it('should update its query part on slider change', () => { - const context: any = { - queryFragments: {}, - update: () => {} - }; - - spyOn(context, 'update').and.stub(); - - component.context = context; - component.id = 'contentSize'; - component.settings = { field: 'cm:content.size' }; + component.settings['field'] = 'cm:content.size'; component.value = 10; fixture.detectChanges(); component.onChangedHandler(); - expect(context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 10]'); - expect(context.update).toHaveBeenCalled(); + 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(); component.value = 20; component.onChangedHandler(); - expect(context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 20]'); + expect(component.context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 20]'); + expect(component.context.filterRawParams[component.id]).toEqual(20); }); it('should reset the value for query builder', () => { - const settings: any = { - field: 'field1', - min: 10, - max: 100, - step: 2, - thumbLabel: true - }; - - const context: any = { - queryFragments: {}, - update: () => {} - }; - - component.settings = settings; - component.context = context; component.value = 20; - component.id = 'slider'; - spyOn(context, 'update').and.stub(); fixture.detectChanges(); component.reset(); - expect(component.value).toBe(settings.min); - expect(context.queryFragments[component.id]).toBe(''); - expect(context.update).toHaveBeenCalled(); + 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(); }); it('should reset to 0 if min not provided', () => { - const settings: any = { - field: 'field1', - min: null, - max: 100, - step: 2, - thumbLabel: true - }; - - const context: any = { - queryFragments: {}, - update: () => {} - }; - - component.settings = settings; - component.context = context; + component.settings.min = null; component.value = 20; - component.id = 'slider'; - spyOn(context, 'update').and.stub(); fixture.detectChanges(); component.reset(); expect(component.value).toBe(0); - expect(context.queryFragments['slider']).toBe(''); - expect(context.update).toHaveBeenCalled(); + expect(component.context.queryFragments['slider']).toBe(''); + expect(component.context.update).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(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + component.context.populateFilters.next({ slider: 20 }); + fixture.detectChanges(); + + expect(component.displayValue$.next).toHaveBeenCalledWith('20 '); + expect(component.value).toBe(20); + expect(component.context.filterRawParams[component.id]).toBe(20); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); }); }); 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 f65bd2fcbbb..b3d733d5d24 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 @@ -15,11 +15,12 @@ * limitations under the License. */ -import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; -import { Subject } from 'rxjs'; +import { ReplaySubject, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { MatSliderModule } from '@angular/material/slider'; import { FormsModule } from '@angular/forms'; @@ -35,7 +36,11 @@ import { TranslateModule } from '@ngx-translate/core'; encapsulation: ViewEncapsulation.None, host: { class: 'adf-search-slider' } }) -export class SearchSliderComponent implements SearchWidget, OnInit { +export class SearchSliderComponent implements SearchWidget, OnInit, OnDestroy { + /** The numeric value represented by the slider. */ + @Input() + value: number | null; + isActive?: boolean; startValue: any; @@ -47,11 +52,9 @@ export class SearchSliderComponent implements SearchWidget, OnInit { max: number; thumbLabel = false; enableChangeUpdate: boolean; - displayValue$: Subject = new Subject(); + displayValue$ = new ReplaySubject(1); - /** The numeric value represented by the slider. */ - @Input() - value: number | null; + private readonly destroy$ = new Subject(); ngOnInit() { if (this.settings) { @@ -74,6 +77,23 @@ export class SearchSliderComponent implements SearchWidget, OnInit { if (this.startValue) { this.setValue(this.startValue); } + this.context.populateFilters + .asObservable() + .pipe(takeUntil(this.destroy$)) + .subscribe((filtersQueries) => { + if (filtersQueries[this.id]) { + this.value = filtersQueries[this.id]; + this.updateQuery(this.value, false); + } else { + this.reset(false); + } + this.context.filterLoaded.next(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } clear() { @@ -83,9 +103,9 @@ export class SearchSliderComponent implements SearchWidget, OnInit { } } - reset() { + reset(updateContext = true) { this.value = this.min || 0; - this.updateQuery(null); + this.updateQuery(null, updateContext); } onChangedHandler() { @@ -111,7 +131,8 @@ export class SearchSliderComponent implements SearchWidget, OnInit { this.submitValues(); } - private updateQuery(value: number | null) { + private updateQuery(value: number | null, updateContext = true) { + this.context.filterRawParams[this.id] = value; this.displayValue$.next(this.value ? `${this.value} ${this.settings.unit ?? ''}` : ''); if (this.id && this.context && this.settings && this.settings.field) { if (value === null) { @@ -119,7 +140,9 @@ export class SearchSliderComponent implements SearchWidget, OnInit { } else { this.context.queryFragments[this.id] = `${this.settings.field}:[0 TO ${value}]`; } - this.context.update(); + if (updateContext) { + this.context.update(); + } } } } diff --git a/lib/content-services/src/lib/search/components/search-sorting-picker/search-sorting-picker.component.spec.ts b/lib/content-services/src/lib/search/components/search-sorting-picker/search-sorting-picker.component.spec.ts index 3e53f167c29..b5491e8beeb 100644 --- a/lib/content-services/src/lib/search/components/search-sorting-picker/search-sorting-picker.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-sorting-picker/search-sorting-picker.component.spec.ts @@ -17,42 +17,46 @@ import { SearchSortingPickerComponent } from './search-sorting-picker.component'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; -import { AppConfigService } from '@alfresco/adf-core'; -import { SearchConfiguration } from '../../models/search-configuration.interface'; -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ContentTestingModule } from '../../../testing/content.testing.module'; -import { AlfrescoApiService } from '../../../services/alfresco-api.service'; +import { SearchConfiguration } from '../../models'; +import { BaseQueryBuilderService } from '../../services/base-query-builder.service'; describe('SearchSortingPickerComponent', () => { - let queryBuilder: SearchQueryBuilderService; + let fixture: ComponentFixture; let component: SearchSortingPickerComponent; - const buildConfig = (searchSettings): AppConfigService => { - const config = TestBed.inject(AppConfigService); - config.config.search = searchSettings; - return config; + const config: SearchConfiguration = { + sorting: { + options: [ + { key: 'name', label: 'Name', type: 'FIELD', field: 'cm:name', ascending: true }, + { key: 'content.sizeInBytes', label: 'Size', type: 'FIELD', field: 'content.size', ascending: true }, + { key: 'description', label: 'Description', type: 'FIELD', field: 'cm:description', ascending: true } + ], + defaults: [{ key: 'name', type: 'FIELD', field: 'cm:name', ascending: false } as any] + }, + categories: [{ id: 'cat1', enabled: true } as any] + }; + + const queryBuilder: Partial = { + getSortingOptions: () => config.sorting.options, + getPrimarySorting: () => config.sorting.defaults[0], + sorting: config.sorting.options, + update: jasmine.createSpy('update') }; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ContentTestingModule] + imports: [ContentTestingModule], + providers: [ + { + provide: SearchQueryBuilderService, + useValue: queryBuilder + } + ] }); - - const config: SearchConfiguration = { - sorting: { - options: [ - { key: 'name', label: 'Name', type: 'FIELD', field: 'cm:name', ascending: true }, - { key: 'content.sizeInBytes', label: 'Size', type: 'FIELD', field: 'content.size', ascending: true }, - { key: 'description', label: 'Description', type: 'FIELD', field: 'cm:description', ascending: true } - ], - defaults: [{ key: 'name', type: 'FIELD', field: 'cm:name', ascending: false } as any] - }, - categories: [{ id: 'cat1', enabled: true } as any] - }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - queryBuilder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); - component = new SearchSortingPickerComponent(queryBuilder); + fixture = TestBed.createComponent(SearchSortingPickerComponent); + component = fixture.componentInstance; }); it('should load options from query builder', () => { @@ -72,8 +76,6 @@ describe('SearchSortingPickerComponent', () => { }); it('should update query builder each time selection is changed', () => { - spyOn(queryBuilder, 'update').and.stub(); - component.ngOnInit(); component.onValueChanged('description'); @@ -84,8 +86,6 @@ describe('SearchSortingPickerComponent', () => { }); it('should update query builder each time sorting is changed', () => { - spyOn(queryBuilder, 'update').and.stub(); - component.ngOnInit(); component.onSortingChanged(false); 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 850ac804e5a..5a86ec41d4f 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,6 +22,7 @@ import { HarnessLoader } from '@angular/cdk/testing'; 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'; describe('SearchTextComponent', () => { let loader: HarnessLoader; @@ -40,10 +41,13 @@ describe('SearchTextComponent', () => { field: 'cm:name', placeholder: 'Enter the name' }; - component.context = { - queryFragments: {}, - update: () => {} + queryFragments: { + slider: '' + }, + filterRawParams: {}, + populateFilters: new ReplaySubject(1), + update: jasmine.createSpy('update') } as any; loader = TestbedHarnessEnvironment.loader(fixture); @@ -65,8 +69,6 @@ describe('SearchTextComponent', () => { }); it('should update query builder on change', () => { - spyOn(component.context, 'update').and.stub(); - component.onChangedHandler({ target: { value: 'top-secret.doc' @@ -75,6 +77,7 @@ 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(); }); @@ -87,6 +90,7 @@ 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'); component.onChangedHandler({ target: { @@ -96,6 +100,7 @@ describe('SearchTextComponent', () => { expect(component.value).toBe(''); expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.filterRawParams[component.id]).toBe(''); }); it('should show the custom/default name', async () => { @@ -118,10 +123,10 @@ describe('SearchTextComponent', () => { expect(component.value).toBe(''); expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.filterRawParams[component.id]).toBeNull(); }); it('should update query with startValue on init, if provided', () => { - spyOn(component.context, 'update'); component.startValue = 'mock-start-value'; fixture.detectChanges(); @@ -132,7 +137,6 @@ describe('SearchTextComponent', () => { it('should parse value and set query context as blank, and not call query update, if no start value was provided', () => { component.context.queryFragments[component.id] = `cm:name:'secret.pdf'`; - spyOn(component.context, 'update'); component.startValue = undefined; fixture.detectChanges(); @@ -140,4 +144,18 @@ describe('SearchTextComponent', () => { expect(component.value).toBe('secret.pdf'); expect(component.context.update).not.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(); + spyOn(component.displayValue$, 'next').and.stub(); + fixture.detectChanges(); + component.context.populateFilters.next({ text: 'secret.pdf' }); + fixture.detectChanges(); + + expect(component.displayValue$.next).toHaveBeenCalledWith('secret.pdf'); + expect(component.value).toBe('secret.pdf'); + expect(component.context.filterRawParams[component.id]).toBe('secret.pdf'); + expect(component.context.filterLoaded.next).toHaveBeenCalled(); + }); }); 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 cea0b989591..e12aeed9a11 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 @@ -15,11 +15,12 @@ * limitations under the License. */ -import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; -import { Subject } from 'rxjs'; +import { ReplaySubject, Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { MatFormFieldModule } from '@angular/material/form-field'; import { TranslateModule } from '@ngx-translate/core'; @@ -37,7 +38,7 @@ import { MatIconModule } from '@angular/material/icon'; encapsulation: ViewEncapsulation.None, host: { class: 'adf-search-text' } }) -export class SearchTextComponent implements SearchWidget, OnInit { +export class SearchTextComponent implements SearchWidget, OnInit, OnDestroy { /** The content of the text box. */ @Input() value = ''; @@ -48,7 +49,9 @@ export class SearchTextComponent implements SearchWidget, OnInit { startValue: string; isActive = false; enableChangeUpdate = true; - displayValue$: Subject = new Subject(); + displayValue$ = new ReplaySubject(1); + + private readonly destroy$ = new Subject(); ngOnInit() { if (this.context && this.settings && this.settings.pattern) { @@ -70,6 +73,26 @@ export class SearchTextComponent implements SearchWidget, OnInit { } } } + this.context.populateFilters + .asObservable() + .pipe( + map((filtersQueries) => filtersQueries[this.id]), + takeUntil(this.destroy$) + ) + .subscribe((filterQuery) => { + if (filterQuery) { + this.value = filterQuery; + this.updateQuery(this.value, false); + } else { + this.reset(false); + } + this.context.filterLoaded.next(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } clear() { @@ -80,9 +103,9 @@ export class SearchTextComponent implements SearchWidget, OnInit { } } - reset() { + reset(updateContext = true) { this.value = ''; - this.updateQuery(null); + this.updateQuery(null, updateContext); } onChangedHandler(event) { @@ -93,11 +116,14 @@ export class SearchTextComponent implements SearchWidget, OnInit { } } - private updateQuery(value: string) { + private updateQuery(value: string, updateContext = true) { + this.context.filterRawParams[this.id] = value; this.displayValue$.next(value); if (this.context && this.settings && this.settings.field) { this.context.queryFragments[this.id] = value ? `${this.settings.field}:'${this.getSearchPrefix()}${value}${this.getSearchSuffix()}'` : ''; - this.context.update(); + if (updateContext) { + this.context.update(); + } } } diff --git a/lib/content-services/src/lib/search/models/search-widget.interface.ts b/lib/content-services/src/lib/search/models/search-widget.interface.ts index c331ae26aae..3a911334e49 100644 --- a/lib/content-services/src/lib/search/models/search-widget.interface.ts +++ b/lib/content-services/src/lib/search/models/search-widget.interface.ts @@ -17,7 +17,7 @@ import { SearchWidgetSettings } from './search-widget-settings.interface'; import { SearchQueryBuilderService } from '../services/search-query-builder.service'; -import { Subject } from 'rxjs'; +import { ReplaySubject } from 'rxjs'; export interface SearchWidget { id: string; @@ -27,7 +27,7 @@ export interface SearchWidget { isActive?: boolean; startValue: any; /* stream emit value on changes */ - displayValue$: Subject; + displayValue$: ReplaySubject; /* reset the value and update the search */ reset(): void; /* update the search with field value */ 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 eb5eb72c5b3..744f46782c2 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 @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Subject, Observable, from, ReplaySubject } from 'rxjs'; +import { Subject, Observable, from, ReplaySubject, BehaviorSubject } from 'rxjs'; import { AppConfigService } from '@alfresco/adf-core'; import { SearchRequest, @@ -37,8 +37,13 @@ import { FacetField } from '../models/facet-field.interface'; import { FacetFieldBucket } from '../models/facet-field-bucket.interface'; import { SearchForm } from '../models/search-form.interface'; import { AlfrescoApiService } from '../../services/alfresco-api.service'; +import { Buffer } from 'buffer'; +import { inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; export abstract class BaseQueryBuilderService { + private readonly router = inject(Router); + private readonly activatedRoute = inject(ActivatedRoute); private _searchApi: SearchApi; get searchApi(): SearchApi { this._searchApi = this._searchApi ?? new SearchApi(this.alfrescoApiService.getInstance()); @@ -48,6 +53,9 @@ export abstract class BaseQueryBuilderService { /* Stream that emits the search configuration whenever the user change the search forms */ configUpdated = new Subject(); + /* 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(); @@ -60,12 +68,17 @@ export abstract class BaseQueryBuilderService { /* Stream that emits search forms */ searchForms = new ReplaySubject(1); + /* Stream that emits the initial value for some or all search filters */ + populateFilters = new BehaviorSubject<{ [key: string]: any }>({}); + categories: SearchCategory[] = []; queryFragments: { [id: string]: string } = {}; filterQueries: FilterQuery[] = []; + filterRawParams: { [key: string]: any } = {}; paging: { maxItems?: number; skipCount?: number } = null; sorting: SearchSortingDefinition[] = []; sortingOptions: SearchSortingDefinition[] = []; + private encodedQuery: string; private scope: RequestScope; private selectedConfiguration: number; private _userQuery = ''; @@ -88,10 +101,7 @@ export abstract class BaseQueryBuilderService { // TODO: to be supported in future iterations ranges: { [id: string]: SearchRange } = {}; - protected constructor( - protected appConfig: AppConfigService, - protected alfrescoApiService: AlfrescoApiService - ) { + protected constructor(protected readonly appConfig: AppConfigService, protected readonly alfrescoApiService: AlfrescoApiService) { this.resetToDefaults(); } @@ -99,7 +109,14 @@ export abstract class BaseQueryBuilderService { public abstract isFilterServiceActive(): boolean; - public resetToDefaults() { + public resetToDefaults(withNavigate = false) { + if (withNavigate) { + this.router.navigate([], { + queryParams: { q: null }, + relativeTo: this.activatedRoute, + queryParamsHandling: 'merge' + }); + } const currentConfig = this.getDefaultConfiguration(); this.resetSearchOptions(); this.configUpdated.next(currentConfig); @@ -140,6 +157,9 @@ export abstract class BaseQueryBuilderService { this.sortingOptions = []; this.userFacetBuckets = {}; this.scope = null; + this.filterRawParams = {}; + this._userQuery = ''; + this.populateFilters.next({}); } public getSearchFormDetails(): SearchForm[] { @@ -297,12 +317,16 @@ export abstract class BaseQueryBuilderService { /** * Builds and executes the current query. * + * @param updateQueryParams whether query params should be updated with encoded query * @param queryBody query settings */ - async execute(queryBody?: SearchRequest) { + async execute(updateQueryParams = true, queryBody?: SearchRequest) { try { const query = queryBody ? queryBody : this.buildQuery(); if (query) { + if (updateQueryParams) { + this.updateSearchQueryParams(); + } const resultSetPaging: ResultSetPaging = await this.searchApi.search(query); this.executed.next(resultSetPaging); } @@ -461,9 +485,9 @@ export abstract class BaseQueryBuilderService { end: set.end, startInclusive: set.startInclusive, endInclusive: set.endInclusive - }) as any + } as any) ) - }) as any + } as any) ) }; } @@ -477,7 +501,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) { @@ -522,7 +548,7 @@ export abstract class BaseQueryBuilderService { limit: facet.limit, offset: facet.offset, prefix: facet.prefix - }) as any + } as any) ) }; } @@ -543,4 +569,38 @@ export abstract class BaseQueryBuilderService { } return configLabel; } + + /** + * Encodes filter configuration stored in filterRawParams object. + */ + encodeQuery() { + this.encodedQuery = Buffer.from(JSON.stringify(this.filterRawParams)).toString('base64'); + } + + /** + * Encodes existing filters configuration and updates search query param value. + */ + updateSearchQueryParams() { + this.encodeQuery(); + this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams: { q: this.encodedQuery }, + queryParamsHandling: 'merge' + }); + } + + /** + * Builds search query with provided user query, executes query, encodes latest filter config and navigates to search. + * + * @param query user query to search for + * @param searchUrl search url to navigate to + */ + async navigateToSearch(query: string, searchUrl: string) { + this.userQuery = query; + await this.execute(); + await this.router.navigate([searchUrl], { + queryParams: { q: this.encodedQuery }, + queryParamsHandling: 'merge' + }); + } } 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 22fa5a6fcfd..e05081e6672 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 @@ -69,7 +69,7 @@ export class SearchFacetFiltersService implements OnDestroy { this.responseFacets = null; }); - this.queryBuilder.updated.pipe(takeUntil(this.onDestroy$)).subscribe((query) => this.queryBuilder.execute(query)); + this.queryBuilder.updated.pipe(takeUntil(this.onDestroy$)).subscribe((query) => this.queryBuilder.execute(true, query)); this.queryBuilder.executed.pipe(takeUntil(this.onDestroy$)).subscribe((resultSetPaging: ResultSetPaging) => { this.onDataLoaded(resultSetPaging); @@ -447,7 +447,7 @@ export class SearchFacetFiltersService implements OnDestroy { this.responseFacets = []; this.selectedBuckets = []; this.tabbedFacet = null; - this.queryBuilder.resetToDefaults(); + this.queryBuilder.resetToDefaults(true); this.queryBuilder.update(); } } 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 bbc6622f9da..72ab7f3d5e9 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 @@ -23,7 +23,6 @@ import { ContentTestingModule } from '../../testing/content.testing.module'; import { AlfrescoApiService } from '../../services/alfresco-api.service'; describe('SearchHeaderQueryBuilderService', () => { - beforeEach(() => { TestBed.configureTestingModule({ imports: [ContentTestingModule] @@ -36,21 +35,22 @@ describe('SearchHeaderQueryBuilderService', () => { return config; }; + const createQueryBuilder = (searchSettings): SearchHeaderQueryBuilderService => { + let builder: SearchHeaderQueryBuilderService; + TestBed.runInInjectionContext(() => { + const alfrescoApiService = TestBed.inject(AlfrescoApiService); + builder = new SearchHeaderQueryBuilderService(buildConfig(searchSettings), alfrescoApiService, null); + }); + return builder; + }; + it('should load the configuration from app config', () => { const config: SearchConfiguration = { - categories: [ - { id: 'cat1', enabled: true } as any, - { id: 'cat2', enabled: true } as any - ], + categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any], filterQueries: [{ query: 'query1' }, { query: 'query2' }] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchHeaderQueryBuilderService( - buildConfig(config), - alfrescoApiService, - null - ); + const builder = createQueryBuilder(config); builder.categories = []; builder.filterQueries = []; @@ -73,12 +73,7 @@ describe('SearchHeaderQueryBuilderService', () => { filterQueries: [{ query: 'query1' }, { query: 'query2' }] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const service = new SearchHeaderQueryBuilderService( - buildConfig(config), - alfrescoApiService, - null - ); + const service = createQueryBuilder(config); const category = service.getCategoryForColumn('fake-key-1'); expect(category).not.toBeNull(); @@ -87,34 +82,19 @@ describe('SearchHeaderQueryBuilderService', () => { }); it('should have empty user query by default', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchHeaderQueryBuilderService( - buildConfig({}), - alfrescoApiService, - null - ); + const builder = createQueryBuilder({}); expect(builder.userQuery).toBe(''); }); it('should add the extra filter for the parent node', () => { const config: SearchConfiguration = { - categories: [ - { id: 'cat1', enabled: true } as any, - { id: 'cat2', enabled: true } as any - ], + categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any], filterQueries: [{ query: 'query1' }, { query: 'query2' }] }; - const expectedResult = [ - { query: 'PARENT:"workspace://SpacesStore/fake-node-id"' } - ]; + const expectedResult = [{ query: 'PARENT:"workspace://SpacesStore/fake-node-id"' }]; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const searchHeaderService = new SearchHeaderQueryBuilderService( - buildConfig(config), - alfrescoApiService, - null - ); + const searchHeaderService = createQueryBuilder(config); searchHeaderService.setCurrentRootFolderId('fake-node-id'); @@ -122,52 +102,28 @@ describe('SearchHeaderQueryBuilderService', () => { }); it('should not add again the parent filter if that node is already added', () => { - - const expectedResult = [ - { query: 'PARENT:"workspace://SpacesStore/fake-node-id"' } - ]; + const expectedResult = [{ query: 'PARENT:"workspace://SpacesStore/fake-node-id"' }]; const config: SearchConfiguration = { - categories: [ - { id: 'cat1', enabled: true } as any, - { id: 'cat2', enabled: true } as any - ], + categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any], filterQueries: expectedResult }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const searchHeaderService = new SearchHeaderQueryBuilderService( - buildConfig(config), - alfrescoApiService, - null - ); - + const searchHeaderService = createQueryBuilder(config); searchHeaderService.setCurrentRootFolderId('fake-node-id'); - expect(searchHeaderService.filterQueries).toEqual( - expectedResult, - 'Filters are not as expected' - ); + expect(searchHeaderService.filterQueries).toEqual(expectedResult, 'Filters are not as expected'); }); it('should not add duplicate column names in activeFilters', () => { const activeFilter = 'FakeColumn'; const config: SearchConfiguration = { - categories: [ - { id: 'cat1', enabled: true } as any - ], - filterQueries: [ - { query: 'PARENT:"workspace://SpacesStore/fake-node-id' } - ] + categories: [{ id: 'cat1', enabled: true } as any], + filterQueries: [{ query: 'PARENT:"workspace://SpacesStore/fake-node-id' }] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const searchHeaderService = new SearchHeaderQueryBuilderService( - buildConfig(config), - alfrescoApiService, - null - ); + const searchHeaderService = createQueryBuilder(config); expect(searchHeaderService.activeFilters.length).toBe(0); 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 d8a8027ab08..18aadf4d902 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 @@ -23,6 +23,16 @@ import { FacetField } from '../models/facet-field.interface'; import { TestBed } from '@angular/core/testing'; import { ContentTestingModule } from '../../testing/content.testing.module'; import { ADF_SEARCH_CONFIGURATION } from '../search-configuration.token'; +import { ActivatedRoute, Router } from '@angular/router'; + +const buildConfig = (searchSettings = {}): AppConfigService => { + let config: AppConfigService; + TestBed.runInInjectionContext(() => { + config = TestBed.inject(AppConfigService); + }); + config.config.search = searchSettings; + return config; +}; describe('SearchQueryBuilder (runtime config)', () => { const runtimeConfig: SearchConfiguration = {}; @@ -30,32 +40,32 @@ describe('SearchQueryBuilder (runtime config)', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ContentTestingModule], - providers: [ - { provide: ADF_SEARCH_CONFIGURATION, useValue: runtimeConfig } - ] + providers: [{ provide: ADF_SEARCH_CONFIGURATION, useValue: runtimeConfig }] }); }); - const buildConfig = (searchSettings): AppConfigService => { - const config = TestBed.inject(AppConfigService); - config.config.search = searchSettings; - return config; - }; - it('should use custom search configuration via dependency injection', () => { - const builder = TestBed.inject(SearchQueryBuilderService); + let builder: SearchQueryBuilderService; + TestBed.runInInjectionContext(() => { + builder = TestBed.inject(SearchQueryBuilderService); + }); const currentConfig = builder.loadConfiguration(); expect(currentConfig).toEqual(runtimeConfig); }); it('should prioritise runtime config over configuration file', () => { - const config: SearchConfiguration = { + const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any], filterQueries: [{ query: 'query1' }, { query: 'query2' }] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService, runtimeConfig); + + let alfrescoApiService: AlfrescoApiService; + let builder: SearchQueryBuilderService; + TestBed.runInInjectionContext(() => { + alfrescoApiService = TestBed.inject(AlfrescoApiService); + builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService, runtimeConfig); + }); const currentConfig = builder.loadConfiguration(); expect(currentConfig).toEqual(runtimeConfig); @@ -63,16 +73,24 @@ describe('SearchQueryBuilder (runtime config)', () => { }); describe('SearchQueryBuilder', () => { + let router: Router; + let activatedRoute: ActivatedRoute; + beforeEach(() => { TestBed.configureTestingModule({ imports: [ContentTestingModule] }); + router = TestBed.inject(Router); + activatedRoute = TestBed.inject(ActivatedRoute); }); - const buildConfig = (searchSettings = {}): AppConfigService => { - const config = TestBed.inject(AppConfigService); - config.config.search = searchSettings; - return config; + const createQueryBuilder = (config?: any) => { + let builder: SearchQueryBuilderService; + TestBed.runInInjectionContext(() => { + const alfrescoApiService = TestBed.inject(AlfrescoApiService); + builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + }); + return builder; }; it('should reset to defaults', () => { @@ -80,8 +98,8 @@ describe('SearchQueryBuilder', () => { categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any], filterQueries: [{ query: 'query1' }, { query: 'query2' }] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + + const builder = createQueryBuilder(config); builder.categories = []; builder.filterQueries = []; @@ -96,23 +114,18 @@ describe('SearchQueryBuilder', () => { }); it('should have empty user query by default', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); + const builder = createQueryBuilder(); expect(builder.userQuery).toBe(''); }); it('should wrap user query with brackets', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); + const builder = createQueryBuilder(); builder.userQuery = 'my query'; expect(builder.userQuery).toEqual('(my query)'); }); it('should trim user query value', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); + const builder = createQueryBuilder(); builder.userQuery = ' something '; expect(builder.userQuery).toEqual('(something)'); }); @@ -121,9 +134,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: false } as any, { id: 'cat3', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); expect(builder.categories.length).toBe(2); expect(builder.categories[0].id).toBe('cat1'); @@ -135,8 +146,7 @@ describe('SearchQueryBuilder', () => { categories: [], filterQueries: [{ query: 'query1' }, { query: 'query2' }] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); expect(builder.filterQueries.length).toBe(2); expect(builder.filterQueries[0].query).toBe('query1'); @@ -144,10 +154,7 @@ describe('SearchQueryBuilder', () => { }); it('should add new filter query', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); - + const builder = createQueryBuilder(); builder.addFilterQuery('q1'); expect(builder.filterQueries.length).toBe(1); @@ -155,10 +162,7 @@ describe('SearchQueryBuilder', () => { }); it('should not add empty filter query', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); - + const builder = createQueryBuilder(); builder.addFilterQuery(null); builder.addFilterQuery(''); @@ -166,10 +170,7 @@ describe('SearchQueryBuilder', () => { }); it('should not add duplicate filter query', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); - + const builder = createQueryBuilder(); builder.addFilterQuery('q1'); builder.addFilterQuery('q1'); builder.addFilterQuery('q1'); @@ -179,10 +180,7 @@ describe('SearchQueryBuilder', () => { }); it('should remove filter query', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); - + const builder = createQueryBuilder(); builder.addFilterQuery('q1'); builder.addFilterQuery('q2'); expect(builder.filterQueries.length).toBe(2); @@ -193,9 +191,7 @@ describe('SearchQueryBuilder', () => { }); it('should not remove empty query', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); + const builder = createQueryBuilder(); builder.addFilterQuery('q1'); builder.addFilterQuery('q2'); expect(builder.filterQueries.length).toBe(2); @@ -215,9 +211,7 @@ describe('SearchQueryBuilder', () => { ] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); const query = builder.getFacetQuery('query2'); expect(query.query).toBe('q2'); @@ -231,9 +225,7 @@ describe('SearchQueryBuilder', () => { queries: [{ query: 'q1', label: 'query1' }] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); const query1 = builder.getFacetQuery(''); expect(query1).toBeNull(); @@ -252,9 +244,7 @@ describe('SearchQueryBuilder', () => { ] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); const field = builder.getFacetField('Size'); expect(field.label).toBe('Size'); @@ -271,9 +261,7 @@ describe('SearchQueryBuilder', () => { ] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); const field = builder.getFacetField('Missing'); expect(field).toBeFalsy(); @@ -286,9 +274,7 @@ describe('SearchQueryBuilder', () => { fields: [{ field: 'content.size', mincount: 1, label: 'Label with spaces' }] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); const field = builder.getFacetField('Label with spaces'); expect(field.label).toBe('"Label with spaces"'); @@ -299,9 +285,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = null; const compiled = builder.buildQuery(); @@ -312,10 +296,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); - + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; const compiled = builder.buildQuery(); @@ -326,9 +307,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; builder.queryFragments['cat2'] = 'NOT cm:creator:System'; @@ -342,9 +321,7 @@ describe('SearchQueryBuilder', () => { fields: ['field1', 'field2'], categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; @@ -357,10 +334,7 @@ describe('SearchQueryBuilder', () => { fields: [], categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); - + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; const compiled = builder.buildQuery(); @@ -371,9 +345,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; builder.addFilterQuery('query1'); @@ -388,9 +360,7 @@ describe('SearchQueryBuilder', () => { queries: [{ query: 'q1', label: 'q2', group: 'group-name' }] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; const compiled = builder.buildQuery(); @@ -407,9 +377,7 @@ describe('SearchQueryBuilder', () => { ] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; const compiled = builder.buildQuery(); @@ -449,9 +417,7 @@ describe('SearchQueryBuilder', () => { ] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; const compiled = builder.buildQuery(); @@ -485,9 +451,7 @@ describe('SearchQueryBuilder', () => { ] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; const compiled = builder.buildQuery(); @@ -531,9 +495,7 @@ describe('SearchQueryBuilder', () => { ] } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; const compiled = builder.buildQuery(); @@ -550,8 +512,7 @@ describe('SearchQueryBuilder', () => { fields: [], categories: [{ id: 'cat1', enabled: true } as any, { id: 'cat2', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); const sorting: any = { type: 'FIELD', field: 'cm:name', ascending: true }; builder.sorting = [sorting]; @@ -565,8 +526,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.queryFragments['cat1'] = 'cm:name:test'; builder.paging = { maxItems: 5, skipCount: 5 }; @@ -581,8 +541,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.userQuery = 'my query'; builder.queryFragments['cat1'] = 'cm:name:test'; @@ -615,9 +574,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.addUserFacetBucket(field1.field, field1buckets[0]); builder.addUserFacetBucket(field1.field, field1buckets[1]); @@ -630,7 +587,7 @@ describe('SearchQueryBuilder', () => { expect(compiledQuery.query.query).toBe(expectedResult); }); - it('should use highlight in the queries', () => { + it('should use highlight in the queries', () => { const config: SearchConfiguration = { highlight: { prefix: 'my-prefix', @@ -638,9 +595,7 @@ describe('SearchQueryBuilder', () => { mergeContiguous: true } }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.userQuery = 'my query'; builder.queryFragments['cat1'] = 'cm:name:test'; @@ -655,9 +610,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); spyOn(builder, 'buildQuery').and.throwError('some error'); builder.error.subscribe((error) => { @@ -671,9 +624,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { categories: [{ id: 'cat1', enabled: true } as any] }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); spyOn(builder, 'buildQuery').and.throwError('some error'); builder.executed.subscribe((data) => { @@ -686,9 +637,7 @@ describe('SearchQueryBuilder', () => { }); it('should include contain the path and allowableOperations by default', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); + const builder = createQueryBuilder(); builder.userQuery = 'nuka cola quantum'; const searchRequest = builder.buildQuery(); @@ -700,9 +649,7 @@ describe('SearchQueryBuilder', () => { const config: SearchConfiguration = { include: includeConfig }; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(config), alfrescoApiService); + const builder = createQueryBuilder(config); builder.userQuery = 'nuka cola quantum'; const searchRequest = builder.buildQuery(); @@ -710,9 +657,7 @@ describe('SearchQueryBuilder', () => { }); it('should the query contain the pagination', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); + const builder = createQueryBuilder(); builder.userQuery = 'nuka cola quantum'; const mockPagination = { maxItems: 10, @@ -725,9 +670,7 @@ describe('SearchQueryBuilder', () => { }); it('should the query contain the scope in case it is defined', () => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(), alfrescoApiService); + const builder = createQueryBuilder(); const mockScope = { locations: 'mock-location' }; builder.userQuery = 'nuka cola quantum'; builder.setScope(mockScope); @@ -737,15 +680,50 @@ describe('SearchQueryBuilder', () => { }); it('should return empty if array of search config not found', (done) => { - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - const builder = new SearchQueryBuilderService(buildConfig(null), alfrescoApiService); + const builder = createQueryBuilder(null); builder.searchForms.subscribe((forms) => { expect(forms).toEqual([]); done(); }); }); + it('should add user query to filter raw params when query is built', () => { + const builder = createQueryBuilder(); + builder.userQuery = 'nuka cola quantum'; + builder.buildQuery(); + + expect(builder.filterRawParams).toEqual({ userQuery: '(nuka cola quantum)' }); + }); + + it('should encode query from filter raw params and update query params on executing query', (done) => { + spyOn(router, 'navigate'); + const builder = createQueryBuilder(); + builder.userQuery = 'nuka cola quantum'; + builder.executed.subscribe(() => { + expect(builder.filterRawParams).toEqual({ userQuery: '(nuka cola quantum)' }); + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: activatedRoute, + queryParams: { q: 'eyJ1c2VyUXVlcnkiOiIobnVrYSBjb2xhIHF1YW50dW0pIn0=' }, + queryParamsHandling: 'merge' + }); + done(); + }); + builder.execute(); + }); + + it('should encode query from filter raw params and update query params on navigating to search', async () => { + spyOn(router, 'navigate'); + const builder = createQueryBuilder(); + await builder.navigateToSearch('test query', '/search'); + + expect(builder.filterRawParams).toEqual({ userQuery: '(test query)' }); + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: activatedRoute, + queryParams: { q: 'eyJ1c2VyUXVlcnkiOiIodGVzdCBxdWVyeSkifQ==' }, + queryParamsHandling: 'merge' + }); + }); + describe('Multiple search configuration', () => { let configs: SearchConfiguration[]; let builder: SearchQueryBuilderService; @@ -768,9 +746,7 @@ describe('SearchQueryBuilder', () => { default: false } ]; - const alfrescoApiService = TestBed.inject(AlfrescoApiService); - - builder = new SearchQueryBuilderService(buildConfig(configs), alfrescoApiService); + builder = createQueryBuilder(configs); }); it('should pick the default configuration from list', () => { diff --git a/lib/content-services/src/lib/services/alfresco-api.service.ts b/lib/content-services/src/lib/services/alfresco-api.service.ts index 24f7a60eef7..b0cfcd9306d 100644 --- a/lib/content-services/src/lib/services/alfresco-api.service.ts +++ b/lib/content-services/src/lib/services/alfresco-api.service.ts @@ -27,7 +27,7 @@ export const ALFRESCO_API_FACTORY = new InjectionToken('ALFRESCO_API_FACTORY'); providedIn: 'root' }) export class AlfrescoApiService { - alfrescoApiInitialized: ReplaySubject = new ReplaySubject(1); + alfrescoApiInitialized = new ReplaySubject(1); protected alfrescoApi: AlfrescoApi;