From 53bb04a3d7a613ba5adbe17f62dfefb055470203 Mon Sep 17 00:00:00 2001 From: Anton Ramanovich Date: Wed, 22 Oct 2025 11:31:43 +0200 Subject: [PATCH] [ACS-9166]: restores config-based saved searches approach --- .../src/lib/common/public-api.ts | 2 + .../saved-searches-legacy.service.spec.ts | 197 +++++++++++++++ .../services/saved-searches-legacy.service.ts | 227 ++++++++++++++++++ 3 files changed, 426 insertions(+) create mode 100644 lib/content-services/src/lib/common/services/saved-searches-legacy.service.spec.ts create mode 100644 lib/content-services/src/lib/common/services/saved-searches-legacy.service.ts diff --git a/lib/content-services/src/lib/common/public-api.ts b/lib/content-services/src/lib/common/public-api.ts index 5b561d4501..cca99f28e4 100644 --- a/lib/content-services/src/lib/common/public-api.ts +++ b/lib/content-services/src/lib/common/public-api.ts @@ -25,6 +25,7 @@ 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 './events/file.event'; @@ -38,4 +39,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-legacy.service.spec.ts b/lib/content-services/src/lib/common/services/saved-searches-legacy.service.spec.ts new file mode 100644 index 0000000000..7331f2ad25 --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches-legacy.service.spec.ts @@ -0,0 +1,197 @@ +/*! + * @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 { NodeEntry } from '@alfresco/js-api'; +import { AlfrescoApiServiceMock } from '../../mock'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +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; + + 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({ + imports: [HttpClientTestingModule], + 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(); + }); + + 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(); + }); + }); + }); + + /** + * Prepares default mocks for service + */ + function prepareDefaultMock(): void { + spyOn(authService, 'getUsername').and.callFake(() => testUserName); + const nodeId = 'saved-searches-node-id'; + spyOn(localStorage, 'getItem').and.callFake(() => nodeId); + service.init(); + } +}); 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 0000000000..e558606218 --- /dev/null +++ b/lib/content-services/src/lib/common/services/saved-searches-legacy.service.ts @@ -0,0 +1,227 @@ +/*! + * @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 { NodesApi, NodeEntry } 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'; +import { SavedSearchStrategy } from '../interfaces/saved-searches-strategy.interface'; + +@Injectable({ + providedIn: 'root' +}) +export class SavedSearchesLegacyService implements SavedSearchStrategy { + private _nodesApi: NodesApi; + get nodesApi(): NodesApi { + this._nodesApi = this._nodesApi ?? new NodesApi(this.apiService.getInstance()); + return this._nodesApi; + } + + private readonly _savedSearches$ = new ReplaySubject(1); + readonly savedSearches$ = this._savedSearches$.asObservable(); + + private savedSearchFileNodeId: string; + private currentUserLocalStorageKey: string; + private createFileAttempt = false; + + constructor( + private readonly apiService: AlfrescoApiService, + private readonly authService: AuthenticationService + ) {} + + init(): void { + this.fetchSavedSearches(); + } + + getSavedSearches(): 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.getSavedSearches(); + } + return throwError(() => error); + }) + ) + ) + ); + } + + 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.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(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[]) => + from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(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[]) => + from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(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[]) => + from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(updatedSearches))) + ), + catchError((error) => { + this._savedSearches$.next(previousSavedSearchesOrder); + return throwError(() => error); + }) + ) + .subscribe(); + } + + 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-', { 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()}`; + } + + private fetchSavedSearches(): void { + this.getSavedSearches() + .pipe(take(1)) + .subscribe((searches) => this._savedSearches$.next(searches)); + } +}