mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
[ACS-9166]: Recreating PR changes for support legacy config based saved searches (#11329)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Blob>>;
|
||||
|
||||
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<Blob> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<SavedSearch[]> {
|
||||
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<NodeEntry> {
|
||||
return from(this.nodesApi.updateNodeContent(this.savedSearchFileNodeId, JSON.stringify(searches)));
|
||||
}
|
||||
|
||||
private getSavedSearchesNodeId(): Observable<string> {
|
||||
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<NodeEntry> {
|
||||
return from(this.nodesApi.createNode(parentNodeId, { name: 'config.json', nodeType: 'cm:content' }));
|
||||
}
|
||||
|
||||
private async mapFileContentToSavedSearches(blob: Blob): Promise<Array<SavedSearch>> {
|
||||
return blob.text().then((content) => (content ? JSON.parse(content) : []));
|
||||
}
|
||||
|
||||
private getLocalStorageKey(): string {
|
||||
return `saved-searches-node-id__${this.authService.getUsername()}`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<PreferenceEntry> | Observable<PreferenceEntry>;
|
||||
@@ -33,17 +34,11 @@ export const SAVED_SEARCHES_SERVICE_PREFERENCES = new InjectionToken<SavedSearch
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SavedSearchesService {
|
||||
export class SavedSearchesService extends SavedSearchesBaseService {
|
||||
private savedSearchFileNodeId: string;
|
||||
private _nodesApi: NodesApi;
|
||||
private _preferencesApi: SavedSearchesPreferencesApiService;
|
||||
private preferencesService = inject(SAVED_SEARCHES_SERVICE_PREFERENCES, { optional: true });
|
||||
|
||||
get nodesApi(): NodesApi {
|
||||
this._nodesApi = this._nodesApi ?? new NodesApi(this.apiService.getInstance());
|
||||
return this._nodesApi;
|
||||
}
|
||||
|
||||
get preferencesApi(): SavedSearchesPreferencesApiService {
|
||||
if (this.preferencesService) {
|
||||
this._preferencesApi = this.preferencesService;
|
||||
@@ -54,30 +49,18 @@ export class SavedSearchesService {
|
||||
return this._preferencesApi;
|
||||
}
|
||||
|
||||
readonly savedSearches$ = new ReplaySubject<SavedSearch[]>(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<SavedSearch[]> {
|
||||
protected fetchAllSavedSearches(): Observable<SavedSearch[]> {
|
||||
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<SavedSearch, 'name' | 'description' | 'encodedUrl'>): Observable<NodeEntry> {
|
||||
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<NodeEntry> {
|
||||
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<NodeEntry> {
|
||||
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<NodeEntry> {
|
||||
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<string> {
|
||||
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<SavedSearch[]> {
|
||||
return from(this.nodesApi.getNodeContent(this.savedSearchFileNodeId).then((content) => this.mapFileContentToSavedSearches(content))).pipe(
|
||||
tap((savedSearches) => {
|
||||
|
||||
@@ -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<SavedSearch[]>();
|
||||
|
||||
public updateSpy = jasmine.createSpy('updateSavedSearches').and.returnValue(of({} as NodeEntry));
|
||||
|
||||
constructor(apiService: AlfrescoApiService, authService: AuthenticationService) {
|
||||
super(apiService, authService);
|
||||
}
|
||||
|
||||
protected fetchAllSavedSearches(): Observable<SavedSearch[]> {
|
||||
return this.fetchSubject.asObservable();
|
||||
}
|
||||
|
||||
protected updateSavedSearches(searches: SavedSearch[]): Observable<NodeEntry> {
|
||||
return this.updateSpy(searches);
|
||||
}
|
||||
|
||||
public mockFetch(searches: SavedSearch[]): void {
|
||||
this.fetchSubject.next(searches);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user