Skip to content
Original file line number Diff line number Diff line change
@@ -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<SavedSearch[]>;

init(): void;

/**
* Gets a list of saved searches by user.
*
* @returns SavedSearch list containing user saved searches
*/
getSavedSearches(): Observable<SavedSearch[]>;

/**
* 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<SavedSearch, 'name' | 'description' | 'encodedUrl'>): Observable<NodeEntry>;

/**
* Replace Save Search with new one and also updates the state.
*
* @param updatedSavedSearch - updated Save Search
* @returns NodeEntry
*/
editSavedSearch(updatedSavedSearch: SavedSearch): Observable<NodeEntry>;

/**
* Deletes Save Search and update state.
*
* @param deletedSavedSearch - Save Search to delete
* @returns NodeEntry
*/
deleteSavedSearch(deletedSavedSearch: SavedSearch): Observable<NodeEntry>;

/**
* Reorders saved search place
*
* @param previousIndex - previous index of saved search
* @param currentIndex - new index of saved search
*/
changeOrder(previousIndex: number, currentIndex: number): void;
}
3 changes: 3 additions & 0 deletions lib/content-services/src/lib/common/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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<SavedSearch[]>(1);
readonly savedSearches$: Observable<SavedSearch[]> = 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<SavedSearch[]>;
protected abstract updateSavedSearches(searches: SavedSearch[]): Observable<NodeEntry>;

init(): void {
this.fetchSavedSearches();
}

getSavedSearches(): Observable<SavedSearch[]> {
return this.fetchAllSavedSearches();
}

saveSearch(newSaveSearch: Pick<SavedSearch, 'name' | 'description' | 'encodedUrl'>): Observable<NodeEntry> {
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<NodeEntry> {
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<NodeEntry> {
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));
}
}
Loading
Loading