diff --git a/lib/content-services/src/lib/common/services/saved-searches-base.service.spec.ts b/lib/content-services/src/lib/common/services/saved-searches-base.service.spec.ts new file mode 100644 index 0000000000..e8d822dc78 --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches-base.service.spec.ts @@ -0,0 +1,139 @@ +/*! + * @license + * Copyright © 2005-2025 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 { AlfrescoApiServiceMock } from '../../mock'; +import { MockSavedSearchesService } from '../../mock/saved-searches-derived.mock'; +import { SavedSearch } from '../interfaces/saved-search.interface'; +import { Subject } from 'rxjs'; +import { AuthenticationService } from '@alfresco/adf-core'; + +describe('SavedSearchesBaseService', () => { + let service: MockSavedSearchesService; + + const SAVED_SEARCHES_CONTENT = [ + { name: 'Search 1', description: 'Description 1', encodedUrl: 'url1', order: 0 }, + { name: 'Search 2', description: 'Description 2', encodedUrl: 'url2', order: 1 } + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: AlfrescoApiService, useClass: AlfrescoApiServiceMock }, + { provide: AuthenticationService, useValue: { getUsername: () => {}, onLogin: new Subject() } }, + MockSavedSearchesService + ] + }); + service = TestBed.inject(MockSavedSearchesService); + }); + + it('should emit loaded data in savedSearches$ on init', (done) => { + service.savedSearches$.subscribe((value) => { + expect(value).toEqual(SAVED_SEARCHES_CONTENT); + done(); + }); + service.mockFetch(SAVED_SEARCHES_CONTENT); + service.init(); + }); + + it('should emit updated searches with correct order if total of saved searches is less than 5', (done) => { + service.mockFetch(SAVED_SEARCHES_CONTENT); + const newSearch = { name: 'new-search' } as SavedSearch; + service.saveSearch(newSearch).subscribe(() => { + const args = (service.updateSpy as jasmine.Spy).calls.mostRecent().args[0]; + + expect(args.length).toBe(1 + SAVED_SEARCHES_CONTENT.length); + + expect(args[0]).toEqual({ ...newSearch, order: 0 }); + + service.savedSearches$.subscribe((v) => { + expect(v).toEqual(args); + done(); + }); + }); + }); + + it('should emit updated searches with correct order if total of saved searches is more than 5', (done) => { + const moreSavedSearches = [...SAVED_SEARCHES_CONTENT, ...SAVED_SEARCHES_CONTENT, ...SAVED_SEARCHES_CONTENT]; + service.mockFetch(moreSavedSearches); + const newSearch = { name: 'new-search' } as SavedSearch; + service.saveSearch(newSearch).subscribe(() => { + const args = (service.updateSpy as jasmine.Spy).calls.mostRecent().args[0]; + + expect(args.length).toBe(1 + moreSavedSearches.length); + + expect(args[5]).toEqual({ ...newSearch, order: 5 }); + + service.savedSearches$.subscribe((v) => { + expect(v).toEqual(args); + done(); + }); + }); + }); + + it('should edit a search and emit updated saved searches', (done) => { + service.mockFetch(SAVED_SEARCHES_CONTENT); + service.init(); + const updatedSearch = { name: 'updated-search', order: 0 } as SavedSearch; + service.editSavedSearch(updatedSearch).subscribe(() => { + const args = (service.updateSpy as jasmine.Spy).calls.mostRecent().args[0]; + expect(args[0]).toEqual(updatedSearch); + + service.savedSearches$.subscribe((searches) => { + expect(searches[0]).toEqual(updatedSearch); + done(); + }); + }); + }); + + it('should delete a search and emit updated saved searches', (done) => { + service.mockFetch(SAVED_SEARCHES_CONTENT); + service.init(); + const searchToDelete = { name: 'Search 1', order: 0 } as SavedSearch; + service.deleteSavedSearch(searchToDelete).subscribe(() => { + const args = (service.updateSpy as jasmine.Spy).calls.mostRecent().args[0]; + expect(args.find((s: SavedSearch) => s.name === 'Search 1')).toBeUndefined(); + + service.savedSearches$.subscribe((searches) => { + expect(searches.length).toBe(1); + expect(searches[0].name).toBe('Search 2'); + expect(searches[0].order).toBe(0); + done(); + }); + }); + }); + + it('should change order of saved searches and emit updated saved searches', (done) => { + const updatedOrder = [ + { ...SAVED_SEARCHES_CONTENT[1], order: 0 }, + { ...SAVED_SEARCHES_CONTENT[0], order: 1 } + ]; + service.mockFetch(SAVED_SEARCHES_CONTENT); + service.init(); + service.changeOrder(1, 0); + + service.savedSearches$.subscribe((searches) => { + expect(service.updateSpy).toHaveBeenCalledWith(updatedOrder); + + expect(searches.length).toBe(SAVED_SEARCHES_CONTENT.length); + expect(searches[0]).toEqual(updatedOrder[0]); + expect(searches[1]).toEqual(updatedOrder[1]); + done(); + }); + }); +}); diff --git a/lib/content-services/src/lib/common/services/saved-searches-base.service.ts b/lib/content-services/src/lib/common/services/saved-searches-base.service.ts new file mode 100644 index 0000000000..3bf4bc5333 --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches-base.service.ts @@ -0,0 +1,148 @@ +/*! + * @license + * Copyright © 2005-2025 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 { Injectable } from '@angular/core'; +import { SavedSearchStrategy } from '../interfaces/saved-searches-strategy.interface'; +import { AuthenticationService } from '@alfresco/adf-core'; +import { ReplaySubject, Observable, catchError, switchMap, take, tap, throwError, map } from 'rxjs'; +import { NodeEntry, NodesApi } from '@alfresco/js-api'; +import { SavedSearch } from '../interfaces/saved-search.interface'; +import { AlfrescoApiService } from '../../services/alfresco-api.service'; + +@Injectable() +export abstract class SavedSearchesBaseService implements SavedSearchStrategy { + private _nodesApi: NodesApi; + + protected readonly _savedSearches$ = new ReplaySubject(1); + readonly savedSearches$: Observable = this._savedSearches$.asObservable(); + + get nodesApi(): NodesApi { + this._nodesApi = this._nodesApi ?? new NodesApi(this.apiService.getInstance()); + return this._nodesApi; + } + + constructor( + protected readonly apiService: AlfrescoApiService, + protected readonly authService: AuthenticationService + ) {} + + protected abstract fetchAllSavedSearches(): Observable; + protected abstract updateSavedSearches(searches: SavedSearch[]): Observable; + + init(): void { + this.fetchSavedSearches(); + } + + getSavedSearches(): Observable { + return this.fetchAllSavedSearches(); + } + + saveSearch(newSaveSearch: Pick): Observable { + return this.fetchAllSavedSearches().pipe( + take(1), + switchMap((savedSearches: SavedSearch[]) => { + let updatedSavedSearches: SavedSearch[] = []; + + if (savedSearches.length < 5) { + updatedSavedSearches = [{ ...newSaveSearch, order: 0 }, ...savedSearches]; + } else { + const firstFiveSearches = savedSearches.slice(0, 5); + const restOfSearches = savedSearches.slice(5); + updatedSavedSearches = [...firstFiveSearches, { ...newSaveSearch, order: 5 }, ...restOfSearches]; + } + + updatedSavedSearches = updatedSavedSearches.map((search, index) => ({ ...search, order: index })); + + return this.updateSavedSearches(updatedSavedSearches).pipe(tap(() => this._savedSearches$.next(updatedSavedSearches))); + }), + catchError((error) => { + console.error('Error saving new search:', error); + return throwError(() => error); + }) + ); + } + + editSavedSearch(updatedSavedSearch: SavedSearch): Observable { + let previousSavedSearches: SavedSearch[]; + return this.savedSearches$.pipe( + take(1), + map((savedSearches: SavedSearch[]) => { + previousSavedSearches = [...savedSearches]; + return savedSearches.map((search) => (search.order === updatedSavedSearch.order ? updatedSavedSearch : search)); + }), + tap((updatedSearches: SavedSearch[]) => { + this._savedSearches$.next(updatedSearches); + }), + switchMap((updatedSearches: SavedSearch[]) => this.updateSavedSearches(updatedSearches)), + catchError((error) => { + this._savedSearches$.next(previousSavedSearches); + return throwError(() => error); + }) + ); + } + + deleteSavedSearch(deletedSavedSearch: SavedSearch): Observable { + let previousSavedSearchesOrder: SavedSearch[]; + return this.savedSearches$.pipe( + take(1), + map((savedSearches: SavedSearch[]) => { + previousSavedSearchesOrder = [...savedSearches]; + const updatedSearches = savedSearches.filter((search) => search.order !== deletedSavedSearch.order); + return updatedSearches.map((search, index) => ({ ...search, order: index })); + }), + tap((updatedSearches: SavedSearch[]) => { + this._savedSearches$.next(updatedSearches); + }), + switchMap((updatedSearches: SavedSearch[]) => this.updateSavedSearches(updatedSearches)), + catchError((error) => { + this._savedSearches$.next(previousSavedSearchesOrder); + return throwError(() => error); + }) + ); + } + + changeOrder(previousIndex: number, currentIndex: number): void { + let previousSavedSearchesOrder: SavedSearch[]; + this.savedSearches$ + .pipe( + take(1), + map((savedSearches: SavedSearch[]) => { + previousSavedSearchesOrder = [...savedSearches]; + const [movedSearch] = savedSearches.splice(previousIndex, 1); + savedSearches.splice(currentIndex, 0, movedSearch); + return savedSearches.map((search, index) => ({ ...search, order: index })); + }), + tap((savedSearches: SavedSearch[]) => this._savedSearches$.next(savedSearches)), + switchMap((updatedSearches: SavedSearch[]) => this.updateSavedSearches(updatedSearches)), + catchError((error) => { + this._savedSearches$.next(previousSavedSearchesOrder); + return throwError(() => error); + }) + ) + .subscribe(); + } + + protected resetSavedSearchesStream(): void { + this._savedSearches$.next([]); + } + + private fetchSavedSearches(): void { + this.getSavedSearches() + .pipe(take(1)) + .subscribe((searches) => this._savedSearches$.next(searches)); + } +} diff --git a/lib/content-services/src/lib/mock/saved-searches-derived.mock.ts b/lib/content-services/src/lib/mock/saved-searches-derived.mock.ts new file mode 100644 index 0000000000..adb4955556 --- /dev/null +++ b/lib/content-services/src/lib/mock/saved-searches-derived.mock.ts @@ -0,0 +1,47 @@ +/*! + * @license + * Copyright © 2005-2025 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 { NodeEntry } from '@alfresco/js-api'; +import { SavedSearchesBaseService } from '../common/services/saved-searches-base.service'; +import { of, Observable, ReplaySubject } from 'rxjs'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; +import { AuthenticationService } from '@alfresco/adf-core'; +import { SavedSearch } from '../common/interfaces/saved-search.interface'; +import { Injectable } from '@angular/core'; + +@Injectable() +export class MockSavedSearchesService extends SavedSearchesBaseService { + public fetchSubject = new ReplaySubject(); + + public updateSpy = jasmine.createSpy('updateSavedSearches').and.returnValue(of({} as NodeEntry)); + + constructor(apiService: AlfrescoApiService, authService: AuthenticationService) { + super(apiService, authService); + } + + protected fetchAllSavedSearches(): Observable { + return this.fetchSubject.asObservable(); + } + + protected updateSavedSearches(searches: SavedSearch[]): Observable { + return this.updateSpy(searches); + } + + public mockFetch(searches: SavedSearch[]): void { + this.fetchSubject.next(searches); + } +}