diff --git a/lib/content-services/src/lib/common/interfaces/saved-searches-strategy.interface.ts b/lib/content-services/src/lib/common/interfaces/saved-searches-strategy.interface.ts new file mode 100644 index 00000000000..779925f4fc6 --- /dev/null +++ b/lib/content-services/src/lib/common/interfaces/saved-searches-strategy.interface.ts @@ -0,0 +1,71 @@ +/*! + * @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 { Observable } from 'rxjs'; +import { NodeEntry } from '@alfresco/js-api'; +import { SavedSearch } from './saved-search.interface'; + +/** + * Contract that describes the public API for saved searches strategy. + * Implemented by both the new and legacy SavedSearches services so callers + * can depend on the same shape. + */ +export interface SavedSearchStrategy { + savedSearches$: Observable; + + init(): void; + + /** + * Gets a list of saved searches by user. + * + * @returns SavedSearch list containing user saved searches + */ + getSavedSearches(): Observable; + + /** + * Saves a new search into state and updates state. If there are less than 5 searches, + * it will be pushed on first place, if more it will be pushed to 6th place. + * + * @param newSaveSearch object { name: string, description: string, encodedUrl: string } + * @returns NodeEntry + */ + saveSearch(newSaveSearch: Pick): Observable; + + /** + * Replace Save Search with new one and also updates the state. + * + * @param updatedSavedSearch - updated Save Search + * @returns NodeEntry + */ + editSavedSearch(updatedSavedSearch: SavedSearch): Observable; + + /** + * Deletes Save Search and update state. + * + * @param deletedSavedSearch - Save Search to delete + * @returns NodeEntry + */ + deleteSavedSearch(deletedSavedSearch: SavedSearch): Observable; + + /** + * Reorders saved search place + * + * @param previousIndex - previous index of saved search + * @param currentIndex - new index of saved search + */ + changeOrder(previousIndex: number, currentIndex: number): void; +} diff --git a/lib/content-services/src/lib/common/public-api.ts b/lib/content-services/src/lib/common/public-api.ts index 5b561d4501f..d66f9fcbd6d 100644 --- a/lib/content-services/src/lib/common/public-api.ts +++ b/lib/content-services/src/lib/common/public-api.ts @@ -25,6 +25,8 @@ 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 './services/saved-searches-legacy.service'; +export * from './services/saved-searches-base.service'; export * from './events/file.event'; @@ -38,4 +40,5 @@ export * from './models/allowable-operations.enum'; export * from './interfaces/search-configuration.interface'; export * from './interfaces/saved-search.interface'; +export * from './interfaces/saved-searches-strategy.interface'; export * from './mocks/ecm-user.service.mock'; 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 00000000000..480b2ea5695 --- /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'; +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: SavedSearch[] = [ + { 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 limit (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((savedSearches) => { + expect(savedSearches).toEqual(args); + done(); + }); + }); + }); + + it('should emit updated searches with correct order if total of saved searches is more than limit (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((savedSearches) => { + expect(savedSearches).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((savedSearches) => { + expect(savedSearches[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((savedSearch: SavedSearch) => savedSearch.name === 'Search 1')).toBeUndefined(); + + service.savedSearches$.subscribe((savedSearches) => { + expect(savedSearches.length).toBe(1); + expect(savedSearches[0].name).toBe('Search 2'); + expect(savedSearches[0].order).toBe(0); + done(); + }); + }); + }); + + it('should change order of saved searches and emit updated saved searches', (done) => { + const updatedOrder: SavedSearch[] = [ + { ...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((savedSearches) => { + expect(service.updateSpy).toHaveBeenCalledWith(updatedOrder); + + expect(savedSearches.length).toBe(SAVED_SEARCHES_CONTENT.length); + expect(savedSearches[0]).toEqual(updatedOrder[0]); + expect(savedSearches[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 00000000000..6c32ec47aa6 --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches-base.service.ts @@ -0,0 +1,151 @@ +/*! + * @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'; + +@Injectable() +export abstract class SavedSearchesBaseService implements SavedSearchStrategy { + private _nodesApi: NodesApi; + + private static readonly SAVE_MODE_THRESHOLD = 5; + + 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; + } + + protected 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 { + const limit = SavedSearchesBaseService.SAVE_MODE_THRESHOLD; + return this.fetchAllSavedSearches().pipe( + take(1), + switchMap((savedSearches) => { + let updatedSavedSearches: SavedSearch[] = []; + + if (savedSearches.length < limit) { + updatedSavedSearches = [{ ...newSaveSearch, order: 0 }, ...savedSearches]; + } else { + const upToLimitSearches = savedSearches.slice(0, limit); + const restSearches = savedSearches.slice(limit); + updatedSavedSearches = [...upToLimitSearches, { ...newSaveSearch, order: limit }, ...restSearches]; + } + + 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) => { + previousSavedSearches = [...savedSearches]; + return savedSearches.map((search) => (search.order === updatedSavedSearch.order ? updatedSavedSearch : search)); + }), + tap((updatedSearches) => { + this._savedSearches$.next(updatedSearches); + }), + switchMap((updatedSearches) => 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) => { + 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) => { + 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/common/services/saved-searches-legacy.service.spec.ts b/lib/content-services/src/lib/common/services/saved-searches-legacy.service.spec.ts new file mode 100644 index 00000000000..e2449548f32 --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches-legacy.service.spec.ts @@ -0,0 +1,154 @@ +/*! + * @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'; +import { NodeEntry } from '@alfresco/js-api'; +import { AlfrescoApiServiceMock } from '../../mock'; +import { AuthenticationService } from '@alfresco/adf-core'; +import { Subject } from 'rxjs'; +import { SavedSearchesLegacyService } from './saved-searches-legacy.service'; + +describe('SavedSearchesLegacyService', () => { + let service: SavedSearchesLegacyService; + let authService: AuthenticationService; + let testUserName: string; + let getNodeContentSpy: jasmine.Spy<(nodeId: string) => Promise>; + + 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(): Promise { + return Promise.resolve(new Blob([SAVED_SEARCHES_CONTENT])); + } + + beforeEach(() => { + testUserName = 'test-user'; + TestBed.configureTestingModule({ + providers: [ + { provide: AlfrescoApiService, useClass: AlfrescoApiServiceMock }, + { provide: AuthenticationService, useValue: { getUsername: () => {}, onLogin: new Subject() } }, + SavedSearchesLegacyService + ] + }); + service = TestBed.inject(SavedSearchesLegacyService); + authService = TestBed.inject(AuthenticationService); + spyOn(service.nodesApi, 'getNode').and.callFake(() => Promise.resolve({ entry: { id: testNodeId } } as NodeEntry)); + spyOn(service.nodesApi, 'createNode').and.callFake(() => Promise.resolve({ entry: { id: 'new-node-id' } })); + spyOn(service.nodesApi, 'updateNodeContent').and.callFake(() => Promise.resolve({ entry: {} } as NodeEntry)); + getNodeContentSpy = spyOn(service.nodesApi, 'getNodeContent').and.callFake(() => createBlob()); + }); + + afterEach(() => { + localStorage.removeItem(SAVED_SEARCHES_NODE_ID + testUserName); + }); + + it('should retrieve saved searches from the config.json file', (done) => { + spyOn(authService, 'getUsername').and.callFake(() => testUserName); + spyOn(localStorage, 'getItem').and.callFake(() => testNodeId); + service.init(); + + service.getSavedSearches().subscribe((searches) => { + expect(localStorage.getItem).toHaveBeenCalledWith(SAVED_SEARCHES_NODE_ID + testUserName); + expect(getNodeContentSpy).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 config.json file if it does not exist', (done) => { + const error: Error = { name: 'test', message: '{ "error": { "statusCode": 404 } }' }; + spyOn(authService, 'getUsername').and.callFake(() => testUserName); + service.nodesApi.getNode = jasmine.createSpy().and.returnValue(Promise.reject(error)); + getNodeContentSpy.and.callFake(() => Promise.resolve(new Blob(['']))); + service.init(); + + service.getSavedSearches().subscribe((searches) => { + expect(service.nodesApi.getNode).toHaveBeenCalledWith('-my-', { relativePath: 'config.json' }); + expect(service.nodesApi.createNode).toHaveBeenCalledWith('-my-', jasmine.objectContaining({ name: 'config.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); + const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' }; + service.init(); + + 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 2'); + 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); + service.init(); + + 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); + const newSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3' }; + service.init(); + + 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 2'); + done(); + } + }); + + service.saveSearch(newSearch).subscribe(); + }); +}); diff --git a/lib/content-services/src/lib/common/services/saved-searches-legacy.service.ts b/lib/content-services/src/lib/common/services/saved-searches-legacy.service.ts new file mode 100644 index 00000000000..1cfb8f4657f --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches-legacy.service.ts @@ -0,0 +1,108 @@ +/*! + * @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 { Injectable } from '@angular/core'; +import { Observable, of, from, throwError } from 'rxjs'; +import { catchError, concatMap, first, map } from 'rxjs/operators'; +import { AlfrescoApiService } from '../../services'; +import { SavedSearch } from '../interfaces/saved-search.interface'; +import { AuthenticationService } from '@alfresco/adf-core'; +import { SavedSearchesBaseService } from './saved-searches-base.service'; + +@Injectable({ + providedIn: 'root' +}) +export class SavedSearchesLegacyService extends SavedSearchesBaseService { + private savedSearchFileNodeId: string; + private currentUserLocalStorageKey: string; + private createFileAttempt = false; + + constructor(apiService: AlfrescoApiService, authService: AuthenticationService) { + super(apiService, authService); + } + + protected fetchAllSavedSearches(): Observable { + return this.getSavedSearchesNodeId().pipe( + concatMap(() => + 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.fetchAllSavedSearches(); + } + return throwError(() => error); + }) + ) + ) + ); + } + + protected updateSavedSearches(searches: SavedSearch[]): Observable { + return from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(searches))); + } + + private getSavedSearchesNodeId(): Observable { + const localStorageKey = this.getLocalStorageKey(); + if (this.currentUserLocalStorageKey && this.currentUserLocalStorageKey !== localStorageKey) { + this.resetSavedSearchesStream(); + } + this.currentUserLocalStorageKey = localStorageKey; + let savedSearchesNodeId = localStorage.getItem(this.currentUserLocalStorageKey) ?? ''; + if (savedSearchesNodeId === '') { + return from(this.nodesApi.getNode('-my-', { relativePath: 'config.json' })).pipe( + first(), + concatMap((configNode) => { + savedSearchesNodeId = configNode.entry.id; + localStorage.setItem(this.currentUserLocalStorageKey, savedSearchesNodeId); + this.savedSearchFileNodeId = savedSearchesNodeId; + return savedSearchesNodeId; + }), + catchError((error) => { + const errorStatusCode = JSON.parse(error.message).error.statusCode; + if (errorStatusCode === 404) { + return this.createSavedSearchesNode('-my-').pipe( + first(), + map((node) => { + localStorage.setItem(this.currentUserLocalStorageKey, node.entry.id); + return node.entry.id; + }) + ); + } else { + return throwError(() => error); + } + }) + ); + } else { + this.savedSearchFileNodeId = savedSearchesNodeId; + return of(savedSearchesNodeId); + } + } + + private createSavedSearchesNode(parentNodeId: string): Observable { + return from(this.nodesApi.createNode(parentNodeId, { name: 'config.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()}`; + } +} 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 index 9f025a94223..00fca12d300 100644 --- 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 @@ -20,7 +20,6 @@ import { AlfrescoApiService } from '../../services/alfresco-api.service'; import { AlfrescoApiServiceMock } from '../../mock'; import { NodeEntry } from '@alfresco/js-api'; import { SavedSearchesService } from './saved-searches.service'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; import { AuthenticationService } from '@alfresco/adf-core'; import { Subject } from 'rxjs'; @@ -48,7 +47,6 @@ describe('SavedSearchesService', () => { beforeEach(() => { testUserName = 'test-user'; TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ { provide: AlfrescoApiService, useClass: AlfrescoApiServiceMock }, { provide: AuthenticationService, useValue: { getUsername: () => {}, onLogin: new Subject() } }, @@ -148,36 +146,6 @@ describe('SavedSearchesService', () => { }); }); }); - - it('should edit a search', (done) => { - const updatedSearch = { name: 'Search 3', description: 'Description 3', encodedUrl: 'url3', order: 0 }; - prepareDefaultMock(); - - service.editSavedSearch(updatedSearch).subscribe(() => { - service.savedSearches$.subscribe((searches) => { - expect(searches.length).toBe(2); - expect(searches[0].name).toBe('Search 3'); - expect(searches[0].order).toBe(0); - expect(searches[1].name).toBe('Search 2'); - expect(searches[1].order).toBe(1); - done(); - }); - }); - }); - - it('should delete a search', (done) => { - const searchToDelete = { name: 'Search 1', description: 'Description 1', encodedUrl: 'url1', order: 0 }; - prepareDefaultMock(); - - service.deleteSavedSearch(searchToDelete).subscribe(() => { - service.savedSearches$.subscribe((searches) => { - expect(searches.length).toBe(1); - expect(searches[0].name).toBe('Search 2'); - expect(searches[0].order).toBe(0); - done(); - }); - }); - }); }); describe('Saved searches error handling', () => { @@ -209,13 +177,4 @@ describe('SavedSearchesService', () => { }); }); }); - - /** - * Prepares default mocks for service - */ - function prepareDefaultMock(): void { - spyOn(authService, 'getUsername').and.callFake(() => testUserName); - spyOn(localStorage, 'getItem').and.callFake(() => 'true'); - service.init(); - } }); 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 index 4a30eff2499..32fc945e6e7 100644 --- a/lib/content-services/src/lib/common/services/saved-searches.service.ts +++ b/lib/content-services/src/lib/common/services/saved-searches.service.ts @@ -15,13 +15,14 @@ * limitations under the License. */ -import { NodesApi, NodeEntry, PreferencesApi, ContentFieldsQuery, PreferenceEntry } from '@alfresco/js-api'; +import { NodeEntry, PreferencesApi, ContentFieldsQuery, PreferenceEntry } from '@alfresco/js-api'; import { inject, Injectable, InjectionToken } from '@angular/core'; -import { Observable, of, from, ReplaySubject, throwError } from 'rxjs'; +import { Observable, of, from, 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'; +import { SavedSearchesBaseService } from './saved-searches-base.service'; export interface SavedSearchesPreferencesApiService { getPreference: (personId: string, preferenceName: string, opts?: ContentFieldsQuery) => Promise | Observable; @@ -33,17 +34,11 @@ export const SAVED_SEARCHES_SERVICE_PREFERENCES = new InjectionToken(1); - - constructor( - private readonly apiService: AlfrescoApiService, - private readonly authService: AuthenticationService - ) {} - - init(): void { - this.fetchSavedSearches(); + constructor(apiService: AlfrescoApiService, authService: AuthenticationService) { + super(apiService, authService); } - /** - * Gets a list of saved searches by user. - * - * @returns SavedSearch list containing user saved searches - */ - getSavedSearches(): Observable { + protected fetchAllSavedSearches(): Observable { const savedSearchesMigrated = localStorage.getItem(this.getLocalStorageKey()) ?? ''; if (savedSearchesMigrated === 'true') { return this.getSavedSearchesFromPreferenceApi(); } else { return this.getSavedSearchesNodeId().pipe( take(1), - concatMap(() => { + switchMap(() => { if (this.savedSearchFileNodeId !== '') { return this.migrateSavedSearches(); } else { @@ -89,141 +72,12 @@ export class SavedSearchesService { } } - /** - * Saves a new search into state and updates state. If there are less than 5 searches, - * it will be pushed on first place, if more it will be pushed to 6th place. - * - * @param newSaveSearch object { name: string, description: string, encodedUrl: string } - * @returns NodeEntry - */ - saveSearch(newSaveSearch: Pick): Observable { - return this.getSavedSearches().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 from(this.preferencesApi.updatePreference('-me-', 'saved-searches', JSON.stringify(updatedSavedSearches))).pipe( - map((preference) => JSON.parse(preference.entry.value)), - tap(() => this.savedSearches$.next(updatedSavedSearches)) - ); - }), - catchError((error) => { - console.error('Error saving new search:', error); - return throwError(() => error); - }) + protected updateSavedSearches(updatedSavedSearches: SavedSearch[]): Observable { + return from(this.preferencesApi.updatePreference('-me-', 'saved-searches', JSON.stringify(updatedSavedSearches))).pipe( + map((preference) => JSON.parse(preference.entry.value)) ); } - /** - * Replace Save Search with new one and also updates the state. - * - * @param updatedSavedSearch - updated Save Search - * @returns NodeEntry - */ - 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[]) => - from(this.preferencesApi.updatePreference('-me-', 'saved-searches', JSON.stringify(updatedSearches))).pipe( - map((preference) => JSON.parse(preference.entry.value)) - ) - ), - catchError((error) => { - this.savedSearches$.next(previousSavedSearches); - return throwError(() => error); - }) - ); - } - - /** - * Deletes Save Search and update state. - * - * @param deletedSavedSearch - Save Search to delete - * @returns NodeEntry - */ - 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[]) => - from(this.preferencesApi.updatePreference('-me-', 'saved-searches', JSON.stringify(updatedSearches))).pipe( - map((preference) => JSON.parse(preference.entry.value)) - ) - ), - catchError((error) => { - this.savedSearches$.next(previousSavedSearchesOrder); - return throwError(() => error); - }) - ); - } - - /** - * Reorders saved search place - * - * @param previousIndex - previous index of saved search - * @param currentIndex - new index of saved search - */ - 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[]) => - from(this.preferencesApi.updatePreference('-me-', 'saved-searches', JSON.stringify(updatedSearches))).pipe( - map((preference) => JSON.parse(preference.entry.value)) - ) - ), - catchError((error) => { - this.savedSearches$.next(previousSavedSearchesOrder); - return throwError(() => error); - }) - ) - .subscribe(); - } - private getSavedSearchesNodeId(): Observable { return from(this.nodesApi.getNode('-my-', { relativePath: 'config.json' })).pipe( first(), @@ -252,12 +106,6 @@ export class SavedSearchesService { return `saved-searches-${this.authService.getUsername()}-migrated`; } - private fetchSavedSearches(): void { - this.getSavedSearches() - .pipe(take(1)) - .subscribe((searches) => this.savedSearches$.next(searches)); - } - private migrateSavedSearches(): Observable { return from(this.nodesApi.getNodeContent(this.savedSearchFileNodeId).then((content) => this.mapFileContentToSavedSearches(content))).pipe( tap((savedSearches) => { 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 00000000000..a2da535b652 --- /dev/null +++ b/lib/content-services/src/lib/mock/saved-searches-derived.mock.ts @@ -0,0 +1,46 @@ +/*! + * @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 { of, Observable, ReplaySubject } from 'rxjs'; +import { AlfrescoApiService } from '../services'; +import { AuthenticationService } from '@alfresco/adf-core'; +import { SavedSearch, SavedSearchesBaseService } from '../common'; +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); + } +}