[ACS-10730] The ‘Save Changes’ button becomes disabled after modifying the search or filter. (#4929)

This commit is contained in:
dominikiwanekhyland
2025-12-19 07:18:37 +01:00
committed by GitHub
parent 9cd6c9154b
commit 7df4e84729
12 changed files with 246 additions and 82 deletions

View File

@@ -60,6 +60,7 @@
mat-button
acaSaveSearch
[acaSaveSearchQuery]="encodedQuery"
(searchSaved)="onSaveSearch()"
[disabled]="!encodedQuery"
class="aca-content__save-search-action"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}"

View File

@@ -266,6 +266,17 @@ describe('SearchComponent', () => {
expect(component.initialSavedSearch).toEqual({ name: 'test', encodedUrl: encodeQuery({ name: 'test' }), order: 0 });
});
it('should get initial saved search after creating a new one', () => {
route.queryParams = of({ q: encodeQuery({ name: 'test' }) });
component.onSaveSearch();
expect(component.initialSavedSearch).toEqual({ name: 'test', encodedUrl: encodeQuery({ name: 'test' }), order: 0 });
});
it('should clear context save search in service on component destroy', () => {
component.ngOnDestroy();
expect(TestBed.inject(SavedSearchesContextService).currentContextSavedSearch).toBeUndefined();
});
it('should render a menu with 2 options when initial saved search is found', async () => {
route.queryParams = of({ q: encodeQuery({ name: 'test' }) });
component.ngOnInit();

View File

@@ -22,7 +22,7 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ChangeDetectorRef, Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { NodeEntry, Pagination, ResultSetPaging } from '@alfresco/js-api';
import { ActivatedRoute, NavigationStart } from '@angular/router';
import {
@@ -131,7 +131,7 @@ import { SavedSearchesContextService } from '../../../services/saved-searches-co
encapsulation: ViewEncapsulation.None,
styleUrls: ['./search-results.component.scss']
})
export class SearchResultsComponent extends PageComponent implements OnInit {
export class SearchResultsComponent extends PageComponent implements OnInit, OnDestroy {
private notificationService = inject(NotificationService);
infoDrawerPreview$ = this.store.select(infoDrawerPreview);
@@ -214,19 +214,9 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.columns = this.extensions.documentListPresets.searchResults || [];
if (this.route) {
this.route.queryParams
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((params) =>
this.savedSearchesService.savedSearches$.pipe(
first(),
map((savedSearches) => savedSearches.find((savedSearch) => savedSearch.encodedUrl === encodeURIComponent(params[this.queryParamName])))
)
)
)
.subscribe((savedSearches) => {
this.initialSavedSearch = savedSearches;
});
this.selectInitialSavedSearch().subscribe((savedSearches) => {
this.initialSavedSearch = savedSearches;
});
combineLatest([
this.route.queryParams,
@@ -269,6 +259,10 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
}
}
ngOnDestroy(): void {
this.savedSearchesService.currentContextSavedSearch = undefined;
}
onSearchError(error: { message: any }) {
let message: string;
try {
@@ -362,6 +356,15 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
});
}
onSaveSearch(): void {
this.selectInitialSavedSearch()
.pipe(take(1))
.subscribe((savedSearch) => {
this.initialSavedSearch = savedSearch;
this.savedSearchesService.currentContextSavedSearch = savedSearch;
});
}
private shouldExecuteQuery(navigationStartEvent: NavigationStart | null, query: string | undefined): boolean {
const hasQueryChanged = query !== this.previousEncodedQuery;
this.previousEncodedQuery = query;
@@ -374,4 +377,20 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
return !!query;
}
}
private selectInitialSavedSearch(): Observable<SavedSearch> {
return this.route.queryParams.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((params) =>
this.savedSearchesService.savedSearches$.pipe(
first(),
map(
(savedSearches) =>
savedSearches.find((savedSearch) => savedSearch.encodedUrl === encodeURIComponent(params[this.queryParamName])) ||
this.savedSearchesService.currentContextSavedSearch
)
)
)
);
}
}

View File

@@ -74,12 +74,12 @@ describe('SaveSearchDialogComponent', () => {
expect(savedSearchesService.saveSearch).not.toHaveBeenCalled();
});
it('should save search, show snackbar message and close modal if form is valid', fakeAsync(() => {
it('should save search, show snackbar message and close modal with emitting true value if form is valid', fakeAsync(() => {
spyOn(savedSearchesService, 'saveSearch').and.callThrough();
spyOn(notificationService, 'showInfo');
setFormValuesAndSubmit();
expect(notificationService.showInfo).toHaveBeenCalledWith('APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_SUCCESS');
expect(dialogRef.close).toHaveBeenCalled();
expect(dialogRef.close).toHaveBeenCalledWith(true);
}));
it('should show snackbar error if there is save error', fakeAsync(() => {

View File

@@ -96,7 +96,7 @@ export class SaveSearchDialogComponent {
.pipe(take(1))
.subscribe({
next: () => {
this.dialog.close();
this.dialog.close(true);
this.notificationService.showInfo('APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_SUCCESS');
this.disableSubmitButton = false;
},

View File

@@ -24,19 +24,24 @@
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { SaveSearchDirective } from './save-search.directive';
import { SaveSearchDialogComponent } from '../dialog/save-search-dialog.component';
@Component({
selector: 'app-test-component',
template: '<div acaSaveSearch="searchQuery" acaSaveSearchQuery="encodedQuery"></div>',
template: '<div acaSaveSearch="searchQuery" (searchSaved)="onSaveSearchSuccess($event)" acaSaveSearchQuery="encodedQuery"></div>',
imports: [SaveSearchDirective]
})
class TestComponent {
searchQuery = 'encodedQuery';
isSavedWithSuccess = false;
onSaveSearchSuccess(value: boolean): void {
this.isSavedWithSuccess = value;
}
}
describe('SaveSearchDirective', () => {
@@ -57,7 +62,7 @@ describe('SaveSearchDirective', () => {
provide: MatDialog,
useValue: {
open: () => ({
afterClosed: jasmine.createSpy('afterClosed').and.returnValue(of(null))
afterClosed: jasmine.createSpy('afterClosed').and.returnValue(of(true))
})
}
}
@@ -86,4 +91,20 @@ describe('SaveSearchDirective', () => {
expect(dialog.open).toHaveBeenCalledWith(SaveSearchDialogComponent, expectedConfig);
});
it('should emit event on save search success', () => {
const afterClosed$ = new Subject<boolean>();
spyOn(dialog, 'open').and.returnValue({
afterClosed: () => afterClosed$.asObservable()
} as MatDialogRef<SaveSearchDialogComponent>);
element.triggerEventHandler('click', event);
afterClosed$.next(true);
afterClosed$.complete();
fixture.detectChanges();
expect(fixture.componentInstance.isSavedWithSuccess).toBe(true);
});
});

View File

@@ -22,9 +22,10 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
import { DestroyRef, Directive, ElementRef, EventEmitter, HostListener, inject, Input, Output } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { SaveSearchDialogComponent } from '../dialog/save-search-dialog.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
interface SaveSearchDirectiveDialogData {
searchUrl: string;
@@ -39,10 +40,13 @@ export class SaveSearchDirective {
@Input()
acaSaveSearchQuery: string;
constructor(
private readonly dialogRef: MatDialog,
private readonly elementRef: ElementRef<HTMLElement>
) {}
/** Outputs a true value when search was successfully saved */
@Output()
searchSaved = new EventEmitter<boolean>();
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef = inject(MatDialog);
private readonly elementRef = inject(ElementRef<HTMLElement>);
@HostListener('click', ['$event'])
onClick(event: MouseEvent) {
@@ -52,7 +56,15 @@ export class SaveSearchDirective {
}
private openDialog(): void {
this.dialogRef.open(SaveSearchDialogComponent, { ...this.getDialogConfig(), restoreFocus: true });
const dialog = this.dialogRef.open(SaveSearchDialogComponent, { ...this.getDialogConfig(), restoreFocus: true });
dialog
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value: boolean) => {
if (value) {
this.searchSaved.emit(value);
}
});
}
private getDialogConfig(): { data: SaveSearchDirectiveDialogData } {

View File

@@ -1,3 +1,3 @@
@if (item) {
<app-expand-menu [item]="item" />
<app-expand-menu [item]="item" (actionClicked)="onActionClicked($event)" />
}

View File

@@ -28,6 +28,7 @@ import { AppTestingModule } from '../../../../testing/app-testing.module';
import { Observable, of } from 'rxjs';
import { SavedSearchesContextService } from '../../../../services/saved-searches-context.service';
import { SavedSearch } from '@alfresco/adf-content-services';
import { NavBarLinkRef } from '@alfresco/adf-extensions';
describe('SaveSearchSidenavComponent', () => {
let fixture: ComponentFixture<SaveSearchSidenavComponent>;
@@ -36,8 +37,8 @@ describe('SaveSearchSidenavComponent', () => {
beforeEach(() => {
const mockService: Partial<SavedSearchesContextService> = {
currentContextSavedSearch: undefined,
init: (): void => {},
get savedSearches$(): Observable<SavedSearch[]> {
return of([]);
}
@@ -94,4 +95,46 @@ describe('SaveSearchSidenavComponent', () => {
id: 'search1'
});
}));
describe('onActionClicked', () => {
beforeEach(() => {
spyOnProperty(savedSearchesService, 'savedSearches$', 'get').and.returnValue(of([{ name: 'abc', order: 0, encodedUrl: 'abc' }]));
component.ngOnInit();
fixture.detectChanges();
});
it('should set currentContextSavedSearch when matching saved search is found', () => {
const selectedLinkRef: NavBarLinkRef = {
id: 'search-test',
icon: '',
title: 'abc',
description: 'test',
route: 'search?q=encoded',
url: 'search?q=encoded'
};
component.onActionClicked(selectedLinkRef);
expect(component.savedSearchesService.currentContextSavedSearch).toEqual({
name: 'abc',
encodedUrl: 'abc',
order: 0
});
});
it('should set currentContextSavedSearch to undefined when no matching saved search is found', () => {
const selectedLinkRef: NavBarLinkRef = {
id: 'search-unknown',
icon: '',
title: 'Unknown Search',
description: 'Unknown Search',
route: 'search?q=unknown',
url: 'search?q=unknown'
};
component.onActionClicked(selectedLinkRef);
expect(component.savedSearchesService.currentContextSavedSearch).toBeUndefined();
});
});
});

View File

@@ -42,17 +42,19 @@ export class SaveSearchSidenavComponent implements OnInit {
translationService = inject(TranslationService);
item: NavBarLinkRef;
private savedSearchCount = 0;
private savedSearches: SavedSearch[];
private readonly manageSearchesId = 'manage-saved-searches';
private readonly destroyRef = inject(DestroyRef);
private readonly userPreferenceService = inject(UserPreferencesService);
private savedSearchCount = 0;
ngOnInit() {
this.savedSearchesService.init();
this.savedSearchesService.savedSearches$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((savedSearches) => {
this.item = this.createNavBarLinkRef(savedSearches);
this.savedSearchCount = savedSearches.length;
this.savedSearches = savedSearches;
});
this.userPreferenceService
.select(UserPreferenceValues.Locale)
@@ -64,6 +66,11 @@ export class SaveSearchSidenavComponent implements OnInit {
});
}
onActionClicked(selectedLinkRef: NavBarLinkRef): void {
const selectedSavedSearch = this.savedSearches?.find((savedSearch) => savedSearch.name === selectedLinkRef.title);
this.savedSearchesService.currentContextSavedSearch = selectedSavedSearch;
}
private createNavBarLinkRef(children: SavedSearch[]): NavBarLinkRef {
const mappedChildren = children
.map((child) => ({

View File

@@ -23,11 +23,12 @@
*/
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { of, Subject } from 'rxjs';
import { SavedSearchesContextService } from './saved-searches-context.service';
import { SavedSearch, SavedSearchesLegacyService, SavedSearchesService } from '@alfresco/adf-content-services';
import { IsFeatureSupportedInCurrentAcsPipe } from '../pipes/is-feature-supported.pipe';
import { NodeEntry } from '@alfresco/js-api';
describe('SavedSearchesContextService', () => {
let legacySpy: jasmine.SpyObj<SavedSearchesLegacyService>;
@@ -38,7 +39,6 @@ describe('SavedSearchesContextService', () => {
beforeEach(() => {
legacySpy = jasmine.createSpyObj('SavedSearchesLegacyService', [
'savedSearches$',
'init',
'getSavedSearches',
'saveSearch',
@@ -46,9 +46,12 @@ describe('SavedSearchesContextService', () => {
'deleteSavedSearch',
'changeOrder'
]);
legacySpy.getSavedSearches.and.returnValue(of([]));
legacySpy.saveSearch.and.returnValue(of({} as NodeEntry));
legacySpy.editSavedSearch.and.returnValue(of({} as NodeEntry));
legacySpy.deleteSavedSearch.and.returnValue(of({} as NodeEntry));
modernSpy = jasmine.createSpyObj('SavedSearchesService', [
'savedSearches$',
'init',
'getSavedSearches',
'saveSearch',
@@ -56,6 +59,10 @@ describe('SavedSearchesContextService', () => {
'deleteSavedSearch',
'changeOrder'
]);
modernSpy.getSavedSearches.and.returnValue(of([]));
modernSpy.saveSearch.and.returnValue(of({} as NodeEntry));
modernSpy.editSavedSearch.and.returnValue(of({} as NodeEntry));
modernSpy.deleteSavedSearch.and.returnValue(of({} as NodeEntry));
isSupported = new Subject<boolean>();
@@ -79,53 +86,70 @@ describe('SavedSearchesContextService', () => {
isSupported.next(true);
});
it('should use modern service when feature is supported', () => {
it('should use modern service when feature is supported', (done) => {
service.init();
expect(legacySpy.init).not.toHaveBeenCalled();
expect(modernSpy.init).toHaveBeenCalled();
setTimeout(() => {
expect(legacySpy.init).not.toHaveBeenCalled();
expect(modernSpy.init).toHaveBeenCalled();
done();
});
});
it('should delegate init() call to a current strategy', () => {
it('should delegate init() call to a current strategy', (done) => {
service.init();
expect(modernSpy.init).toHaveBeenCalled();
setTimeout(() => {
expect(modernSpy.init).toHaveBeenCalled();
done();
});
});
it('should delegate getSavedSearches() call to a current strategy', () => {
service.getSavedSearches();
expect(modernSpy.getSavedSearches).toHaveBeenCalled();
it('should delegate getSavedSearches() call to a current strategy', (done) => {
service.getSavedSearches().subscribe(() => {
expect(modernSpy.getSavedSearches).toHaveBeenCalled();
done();
});
});
it('should delegate saveSearch() call to a current strategy', () => {
it('should delegate saveSearch() call to a current strategy', (done) => {
const newSavedSearch = { name: 'Test Search', description: 'Test Description', encodedUrl: 'http://example.com' };
service.saveSearch(newSavedSearch);
expect(modernSpy.saveSearch).toHaveBeenCalledWith(newSavedSearch);
service.saveSearch(newSavedSearch).subscribe(() => {
expect(modernSpy.saveSearch).toHaveBeenCalledWith(newSavedSearch);
done();
});
});
it('should delegate editSavedSearch() call to a current strategy', () => {
it('should delegate editSavedSearch() call to a current strategy', (done) => {
const updatedSavedSearch = {
name: 'Updated Search',
description: 'Updated Description',
encodedUrl: 'http://example.com',
order: 1
};
service.editSavedSearch(updatedSavedSearch);
expect(modernSpy.editSavedSearch).toHaveBeenCalledWith(updatedSavedSearch);
service.editSavedSearch(updatedSavedSearch).subscribe(() => {
expect(modernSpy.editSavedSearch).toHaveBeenCalledWith(updatedSavedSearch);
done();
});
});
it('should delegate deleteSavedSearch() call to a current strategy', () => {
it('should delegate deleteSavedSearch() call to a current strategy', (done) => {
const deletedSavedSearch = {
name: 'Deleted Search',
description: 'Deleted Description',
encodedUrl: 'http://example.com',
order: 2
};
service.deleteSavedSearch(deletedSavedSearch);
expect(modernSpy.deleteSavedSearch).toHaveBeenCalledWith(deletedSavedSearch);
service.deleteSavedSearch(deletedSavedSearch).subscribe(() => {
expect(modernSpy.deleteSavedSearch).toHaveBeenCalledWith(deletedSavedSearch);
done();
});
});
it('should delegate changeOrder() call to a current strategy', () => {
it('should delegate changeOrder() call to a current strategy', (done) => {
service.changeOrder(0, 1);
expect(modernSpy.changeOrder).toHaveBeenCalledWith(0, 1);
setTimeout(() => {
expect(modernSpy.changeOrder).toHaveBeenCalledWith(0, 1);
done();
});
});
});
@@ -134,53 +158,70 @@ describe('SavedSearchesContextService', () => {
isSupported.next(false);
});
it('should use legacy service when feature is NOT supported', () => {
it('should use legacy service when feature is NOT supported', (done) => {
service.init();
expect(legacySpy.init).toHaveBeenCalled();
expect(modernSpy.init).not.toHaveBeenCalled();
setTimeout(() => {
expect(legacySpy.init).toHaveBeenCalled();
expect(modernSpy.init).not.toHaveBeenCalled();
done();
});
});
it('should delegate init() call to a current strategy', () => {
it('should delegate init() call to a current strategy', (done) => {
service.init();
expect(legacySpy.init).toHaveBeenCalled();
setTimeout(() => {
expect(legacySpy.init).toHaveBeenCalled();
done();
});
});
it('should delegate getSavedSearches() call to a current strategy', () => {
service.getSavedSearches();
expect(legacySpy.getSavedSearches).toHaveBeenCalled();
it('should delegate getSavedSearches() call to a current strategy', (done) => {
service.getSavedSearches().subscribe(() => {
expect(legacySpy.getSavedSearches).toHaveBeenCalled();
done();
});
});
it('should delegate saveSearch() call to a current strategy', () => {
it('should delegate saveSearch() call to a current strategy', (done) => {
const newSavedSearch = { name: 'Test Search', description: 'Test Description', encodedUrl: 'http://example.com' } as SavedSearch;
service.saveSearch(newSavedSearch);
expect(legacySpy.saveSearch).toHaveBeenCalledWith(newSavedSearch);
service.saveSearch(newSavedSearch).subscribe(() => {
expect(legacySpy.saveSearch).toHaveBeenCalledWith(newSavedSearch);
done();
});
});
it('should delegate editSavedSearch() call to a current strategy', () => {
it('should delegate editSavedSearch() call to a current strategy', (done) => {
const updatedSavedSearch: SavedSearch = {
name: 'Updated Search',
description: 'Updated Description',
encodedUrl: 'http://example.com',
order: 1
};
service.editSavedSearch(updatedSavedSearch);
expect(legacySpy.editSavedSearch).toHaveBeenCalledWith(updatedSavedSearch);
service.editSavedSearch(updatedSavedSearch).subscribe(() => {
expect(legacySpy.editSavedSearch).toHaveBeenCalledWith(updatedSavedSearch);
done();
});
});
it('should delegate deleteSavedSearch() call to a current strategy', () => {
it('should delegate deleteSavedSearch() call to a current strategy', (done) => {
const deletedSavedSearch: SavedSearch = {
name: 'Deleted Search',
description: 'Deleted Description',
encodedUrl: 'http://example.com',
order: 2
};
service.deleteSavedSearch(deletedSavedSearch);
expect(legacySpy.deleteSavedSearch).toHaveBeenCalledWith(deletedSavedSearch);
service.deleteSavedSearch(deletedSavedSearch).subscribe(() => {
expect(legacySpy.deleteSavedSearch).toHaveBeenCalledWith(deletedSavedSearch);
done();
});
});
it('should delegate changeOrder() call to a current strategy', () => {
it('should delegate changeOrder() call to a current strategy', (done) => {
service.changeOrder(0, 1);
expect(legacySpy.changeOrder).toHaveBeenCalledWith(0, 1);
setTimeout(() => {
expect(legacySpy.changeOrder).toHaveBeenCalledWith(0, 1);
done();
});
});
});
});

View File

@@ -33,8 +33,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
providedIn: 'root'
})
export class SavedSearchesContextService implements SavedSearchStrategy {
currentContextSavedSearch: SavedSearch;
private readonly strategy$ = new ReplaySubject<SavedSearchStrategy>(1);
private strategy: SavedSearchStrategy;
constructor(
private readonly legacyService: SavedSearchesLegacyService,
@@ -45,8 +46,8 @@ export class SavedSearchesContextService implements SavedSearchStrategy {
.transform('isPreferencesApiAvailable')
.pipe(takeUntilDestroyed())
.subscribe((isSupported) => {
this.strategy = isSupported ? this.modernService : this.legacyService;
this.strategy$.next(this.strategy);
const strategy = isSupported ? this.modernService : this.legacyService;
this.strategy$.next(strategy);
});
}
@@ -55,26 +56,34 @@ export class SavedSearchesContextService implements SavedSearchStrategy {
}
init(): void {
this.strategy$.pipe(take(1)).subscribe((strategy) => strategy.init());
this.executeOnStrategyVoid((strategy) => strategy.init());
}
getSavedSearches(): Observable<SavedSearch[]> {
return this.strategy.getSavedSearches();
return this.executeOnStrategy((strategy) => strategy.getSavedSearches());
}
saveSearch(newSaveSearch: Pick<SavedSearch, 'name' | 'description' | 'encodedUrl'>): Observable<NodeEntry> {
return this.strategy.saveSearch(newSaveSearch);
return this.executeOnStrategy((strategy) => strategy.saveSearch(newSaveSearch));
}
editSavedSearch(updatedSavedSearch: SavedSearch): Observable<NodeEntry> {
return this.strategy.editSavedSearch(updatedSavedSearch);
return this.executeOnStrategy((strategy) => strategy.editSavedSearch(updatedSavedSearch));
}
deleteSavedSearch(deletedSavedSearch: SavedSearch): Observable<NodeEntry> {
return this.strategy.deleteSavedSearch(deletedSavedSearch);
return this.executeOnStrategy((strategy) => strategy.deleteSavedSearch(deletedSavedSearch));
}
changeOrder(previousIndex: number, currentIndex: number): void {
this.strategy.changeOrder(previousIndex, currentIndex);
this.executeOnStrategyVoid((strategy) => strategy.changeOrder(previousIndex, currentIndex));
}
private executeOnStrategy<T>(action: (strategy: SavedSearchStrategy) => Observable<T>): Observable<T> {
return this.strategy$.pipe(take(1), switchMap(action));
}
private executeOnStrategyVoid(action: (strategy: SavedSearchStrategy) => void): void {
this.strategy$.pipe(take(1)).subscribe(action);
}
}