[MNT-25408]: ensure loader is not shown on nullish query (#4885)

* [MNT-25408]: ensure loader is not shown on nullish query

* [MNT-25408]: moves config change processing to search inout component

* [MNT-25408]: adds tests

* [MNT-25408]: fixes logic; adds tests

* [MNT-25408]: minor fix

* [MNT-25408]: fixes build issue

* [MNT-25408]: unit test fix
This commit is contained in:
Anton Ramanovich
2025-12-17 07:28:18 +01:00
committed by GitHub
parent 1389b33068
commit 3f25c12b6f
5 changed files with 201 additions and 51 deletions
@@ -29,7 +29,9 @@ import { AppStore } from '@alfresco/aca-shared/store';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { SearchInputComponent } from './search-input.component';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { NavigationStart, Router, RouterEvent } from '@angular/router';
import { SearchConfiguration, SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatMenuHarness } from '@angular/material/menu/testing';
@@ -41,6 +43,9 @@ describe('SearchInputComponent', () => {
let store: jasmine.SpyObj<Store<AppStore>>;
let unitTestingUtils: UnitTestingUtils;
let loader: HarnessLoader;
let router: Router;
const routerEventsSubject = new Subject<RouterEvent>();
const configUpdatedSubject = new Subject<SearchConfiguration>();
function getFirstError(): string {
const error = unitTestingUtils.getByDirective(MatError);
@@ -77,18 +82,31 @@ describe('SearchInputComponent', () => {
beforeEach(async () => {
const storeSpy = jasmine.createSpyObj<Store<AppStore>>('Store', ['dispatch', 'pipe']);
const queryBuilderSpy = {
configUpdated: configUpdatedSubject,
removeFilterQuery: () => {}
} as Partial<SearchQueryBuilderService>;
await TestBed.configureTestingModule({
imports: [AppTestingModule, SearchInputComponent],
providers: [{ provide: Store, useValue: storeSpy }]
providers: [
{ provide: Store, useValue: storeSpy },
{ provide: SearchQueryBuilderService, useValue: queryBuilderSpy }
]
}).compileComponents();
fixture = TestBed.createComponent(SearchInputComponent);
component = fixture.componentInstance;
store = TestBed.inject(Store) as jasmine.SpyObj<Store<AppStore>>;
router = TestBed.inject(Router);
store.pipe.and.returnValue(of([]));
fixture.detectChanges();
unitTestingUtils = new UnitTestingUtils(fixture.debugElement);
loader = TestbedHarnessEnvironment.loader(fixture);
Object.defineProperty(router, 'events', {
get: () => routerEventsSubject.asObservable()
});
});
it('should show required error when field is empty and touched', async () => {
@@ -147,4 +165,32 @@ describe('SearchInputComponent', () => {
component.onSearchSubmit({ target: { value: '' } });
expect(store.dispatch).not.toHaveBeenCalled();
});
describe('queryBuilder configUpdated handling', () => {
it('should call searchByOption when searchedWord set and navigation has query params', () => {
spyOn(component, 'searchByOption').and.stub();
component.ngOnInit();
component.searchedWord = 'term';
routerEventsSubject.next(new NavigationStart(1, '/path?q=term'));
configUpdatedSubject.next({});
expect(component.searchByOption).toHaveBeenCalled();
});
it('should NOT call searchByOption when searchedWord set and navigation has NO query params', () => {
component.searchedWord = 'term';
routerEventsSubject.next(new NavigationStart(1, '/path'));
configUpdatedSubject.next({});
spyOn(component, 'searchByOption').and.stub();
component.ngOnInit();
expect(component.searchByOption).not.toHaveBeenCalled();
});
});
});
@@ -28,7 +28,17 @@ import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { AppConfigService, NotificationService } from '@alfresco/adf-core';
import { Component, DestroyRef, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { ActivatedRoute, NavigationSkipped, Params, PRIMARY_OUTLET, Router, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import {
ActivatedRoute,
NavigationSkipped,
NavigationStart,
Params,
PRIMARY_OUTLET,
Router,
UrlSegment,
UrlSegmentGroup,
UrlTree
} from '@angular/router';
import { Store } from '@ngrx/store';
import { SearchInputControlComponent } from '../search-input-control/search-input-control.component';
import { SearchNavigationService } from '../search-navigation.service';
@@ -45,7 +55,7 @@ import { FormsModule } from '@angular/forms';
import { extractSearchedWordFromEncodedQuery } from '../../../utils/aca-search-utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { merge } from 'rxjs/internal/observable/merge';
import { filter, map, withLatestFrom } from 'rxjs';
import { filter, map, startWith, withLatestFrom } from 'rxjs';
@Component({
imports: [
@@ -140,6 +150,23 @@ export class SearchInputComponent implements OnInit, OnDestroy {
}
});
this.queryBuilder.configUpdated
.pipe(
takeUntilDestroyed(this.destroyRef),
withLatestFrom(
this.router.events.pipe(
filter((event): event is NavigationStart => event instanceof NavigationStart),
startWith(null)
)
)
)
.subscribe(([, navigationStartEvent]) => {
const hasQueryParams = navigationStartEvent?.url.includes('?');
if (this.searchedWord && hasQueryParams) {
this.searchByOption();
}
});
this.appHookService.library400Error.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.has400LibraryError = true;
this.hasLibrariesConstraint = this.evaluateLibrariesConstraint();
@@ -1,8 +1,6 @@
<aca-page-layout [class.aca-search-results-active-search-ai-input]="searchAiInputState.active">
<div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active"
[agentId]="searchAiInputState.selectedAgentId" />
<aca-search-ai-input-container *ngIf="searchAiInputState.active" [agentId]="searchAiInputState.selectedAgentId" />
<div class="aca-header-container">
<aca-search-input />
<aca-bulk-actions-dropdown *ngIf="bulkActions && ('isBulkActionsAvailable' | isFeatureSupportedInCurrentAcs | async)" [items]="bulkActions" />
@@ -26,23 +24,25 @@
<div class="aca-content__advanced-filters--header">
<p>{{ 'APP.BROWSE.SEARCH.ADVANCED_FILTERS' | translate }}</p>
<div class="aca-content__advanced-filters--header--action-buttons">
<button
*ngIf="initialSavedSearch !== undefined else saveSearchButton"
mat-button
[disabled]="!encodedQuery"
class="aca-content__save-search-action"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate "
[matMenuTriggerFor]="saveSearchOptionsMenu">
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}
<mat-icon iconPositionEnd>keyboard_arrow_down</mat-icon>
</button>
<button
*ngIf="initialSavedSearch !== undefined; else saveSearchButton"
mat-button
[disabled]="!encodedQuery"
class="aca-content__save-search-action"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate"
[matMenuTriggerFor]="saveSearchOptionsMenu"
>
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}
<mat-icon iconPositionEnd>keyboard_arrow_down</mat-icon>
</button>
<mat-menu #saveSearchOptionsMenu="matMenu">
<button
mat-menu-item
(click)="editSavedSearch(initialSavedSearch)"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_CHANGES' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_CHANGES' | translate ">
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_CHANGES' | translate"
>
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_CHANGES' | translate }}
</button>
<button
@@ -50,7 +50,8 @@
acaSaveSearch
[acaSaveSearchQuery]="encodedQuery"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_AS_NEW' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_AS_NEW' | translate ">
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_AS_NEW' | translate"
>
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.SAVE_AS_NEW' | translate }}
</button>
</mat-menu>
@@ -62,16 +63,19 @@
[disabled]="!encodedQuery"
class="aca-content__save-search-action"
title="{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate ">
[attr.aria-label]="'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate"
>
{{ 'APP.BROWSE.SEARCH.SAVE_SEARCH.ACTION_BUTTON' | translate }}
</button>
</ng-template>
<button
[disabled]="!(areFiltersActive$ | async)"
mat-button
adf-reset-search
class="aca-content__reset-action"
title="{{ 'APP.BROWSE.SEARCH.RESET_ACTION' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.RESET_ACTION' | translate ">
[attr.aria-label]="'APP.BROWSE.SEARCH.RESET_ACTION' | translate"
>
{{ 'APP.BROWSE.SEARCH.RESET' | translate }}
</button>
</div>
@@ -97,7 +101,13 @@
(node-dblclick)="handleNodeClick($event)"
>
<data-columns>
<data-column id="app.search.thumbnail" key="$thumbnail" type="image" [sr-title]="'ADF-DOCUMENT-LIST.LAYOUT.THUMBNAIL'" [sortable]="false">
<data-column
id="app.search.thumbnail"
key="$thumbnail"
type="image"
[sr-title]="'ADF-DOCUMENT-LIST.LAYOUT.THUMBNAIL'"
[sortable]="false"
>
<ng-template let-context>
<aca-custom-thumbnail-column [context]="context" />
</ng-template>
@@ -22,13 +22,13 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { SearchResultsComponent } from './search-results.component';
import { AppConfigService, NotificationService, TranslationService } from '@alfresco/adf-core';
import { Store } from '@ngrx/store';
import { NavigateToFolder } from '@alfresco/aca-shared/store';
import { Pagination, SearchRequest } from '@alfresco/js-api';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { FacetFieldBucket, SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { ActivatedRoute, Event, NavigationStart, Params, Router } from '@angular/router';
import { BehaviorSubject, Observable, of, Subject, throwError } from 'rxjs';
import { AppTestingModule } from '../../../testing/app-testing.module';
@@ -60,9 +60,11 @@ describe('SearchComponent', () => {
let showErrorSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
let showInfoSpy: jasmine.Spy<(message: string, action?: string, interpolateArgs?: any, showAction?: boolean) => MatSnackBarRef<any>>;
let loader: HarnessLoader;
let updatedSubjectMock: Subject<SearchRequest>;
const editSavedSearchesSpy = jasmine.createSpy('editSavedSearch');
const getSavedSearchButton = (): HTMLButtonElement => fixture.nativeElement.querySelector('.aca-content__save-search-action');
const getResetSearchButton = (): HTMLButtonElement => fixture.nativeElement.querySelector('.aca-content__reset-action');
const encodeQuery = (query: any): string => {
return Buffer.from(JSON.stringify(query)).toString('base64');
@@ -72,6 +74,7 @@ describe('SearchComponent', () => {
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' });
queryParams = new Subject();
routerEvents = new Subject();
updatedSubjectMock = new Subject();
const routerMock = jasmine.createSpyObj<Router>('Router', ['navigate'], {
url: '/mock-search-url',
@@ -92,9 +95,7 @@ describe('SearchComponent', () => {
{
provide: SavedSearchesContextService,
useValue: {
getSavedSearches: jasmine
.createSpy('getSavedSearches')
.and.returnValue(of([{ name: 'test', encodedUrl: encodeQuery({ name: 'test' }), order: 0 }])),
savedSearches$: of([{ name: 'test', encodedUrl: encodeQuery({ name: 'test' }), order: 0 }]),
editSavedSearch: editSavedSearchesSpy
}
},
@@ -125,6 +126,8 @@ describe('SearchComponent', () => {
router = TestBed.inject(Router);
route = TestBed.inject(ActivatedRoute);
queryBuilder.updated = updatedSubjectMock;
const notificationService = TestBed.inject(NotificationService);
showErrorSpy = spyOn(notificationService, 'showError');
showInfoSpy = spyOn(notificationService, 'showInfo');
@@ -257,12 +260,6 @@ describe('SearchComponent', () => {
});
});
it('should update the user query whenever configuration changed', () => {
component.searchedWord = 'orange';
queryBuilder.configUpdated.next({ 'app:fields': ['cm:tag'] } as any);
expect(queryBuilder.userQuery).toBe(`((cm:tag:"orange*"))`);
});
it('should get initial saved search when url matches', () => {
route.queryParams = of({ q: encodeQuery({ name: 'test' }) });
component.ngOnInit();
@@ -372,5 +369,73 @@ describe('SearchComponent', () => {
expect(queryBuilder.userQuery).toBe('(test)');
});
it('should set loading to true in updated stream for non-nullish query', fakeAsync(() => {
spyOn(queryBuilder, 'execute').and.stub();
expect(component.isLoading).toBeFalse();
updatedSubjectMock.next(null);
tick();
expect(component.isLoading).toBeFalse();
updatedSubjectMock.next({} as SearchRequest);
tick();
expect(component.isLoading).toBeTrue();
flush();
}));
describe('reset button', () => {
it('should enable the reset button when there are queryFragments', fakeAsync(() => {
queryBuilder.queryFragmentsUpdate.next({ test: 'test-value' });
tick();
fixture.detectChanges();
const resetBtn = getResetSearchButton();
expect(resetBtn).toBeDefined();
expect(resetBtn.getAttribute('disabled')).toBeFalsy();
flush();
}));
it('should enable the reset button when there are userFacetBuckets', fakeAsync(() => {
queryBuilder.userFacetBucketsUpdate.next({ test: [{ label: 'test-value' }] as FacetFieldBucket[] });
tick();
fixture.detectChanges();
const resetBtn = getResetSearchButton();
expect(resetBtn).toBeDefined();
expect(resetBtn.getAttribute('disabled')).toBeFalsy();
flush();
}));
it('should disable the reset button when there are no filters applied', fakeAsync(() => {
queryBuilder.queryFragmentsUpdate.next({});
queryBuilder.userFacetBucketsUpdate.next({});
tick();
fixture.detectChanges();
const resetBtn = getResetSearchButton();
expect(resetBtn).toBeDefined();
expect(resetBtn.getAttribute('disabled')).toBeTruthy();
flush();
}));
});
testHeader(SearchResultsComponent, false);
});
@@ -64,7 +64,7 @@ import {
} from '@alfresco/aca-shared';
import { SearchSortingDefinition } from '@alfresco/adf-content-services/lib/search/models/search-sorting-definition.interface';
import { filter, first, map, startWith, switchMap, take, tap, toArray } from 'rxjs/operators';
import { CommonModule } from '@angular/common';
import { AsyncPipe, CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { SearchInputComponent } from '../search-input/search-input.component';
import { MatProgressBarModule } from '@angular/material/progress-bar';
@@ -84,7 +84,7 @@ import {
formatSearchTerm
} from '../../../utils/aca-search-utils';
import { SaveSearchDirective } from '../search-save/directive/save-search.directive';
import { combineLatest, of } from 'rxjs';
import { combineLatest, merge, Observable, of } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatMenuModule } from '@angular/material/menu';
import { IsFeatureSupportedInCurrentAcsPipe } from '../../../pipes/is-feature-supported.pipe';
@@ -123,7 +123,8 @@ import { SavedSearchesContextService } from '../../../services/saved-searches-co
BulkActionsDropdownComponent,
SearchAiInputContainerComponent,
SaveSearchDirective,
IsFeatureSupportedInCurrentAcsPipe
IsFeatureSupportedInCurrentAcsPipe,
AsyncPipe
],
selector: 'aca-search-results',
templateUrl: './search-results.component.html',
@@ -135,6 +136,8 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
infoDrawerPreview$ = this.store.select(infoDrawerPreview);
protected readonly areFiltersActive$: Observable<boolean>;
searchedWord: string;
queryParamName = 'q';
data: ResultSetPaging;
@@ -168,8 +171,14 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.queryBuilder.configUpdated.pipe(takeUntilDestroyed()).subscribe((searchConfig) => {
this.searchConfig = searchConfig;
this.updateUserQuery();
});
this.areFiltersActive$ = merge(this.queryBuilder.queryFragmentsUpdate, this.queryBuilder.userFacetBucketsUpdate).pipe(
takeUntilDestroyed(),
map((v) => {
return Object.values(v).some((filterValue) => (Array.isArray(filterValue) ? filterValue.length > 0 : !!filterValue));
})
);
}
ngOnInit() {
@@ -179,12 +188,10 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
this.sorting = this.getSorting();
this.subscriptions.push(
this.queryBuilder.updated.subscribe((query) => {
this.queryBuilder.updated.pipe(filter(Boolean)).subscribe(() => {
this.isLoading = true;
if (query) {
this.sorting = this.getSorting();
this.changeDetectorRef.detectChanges();
}
this.sorting = this.getSorting();
this.changeDetectorRef.detectChanges();
}),
this.queryBuilder.executed.subscribe((data) => {
@@ -211,7 +218,7 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((params) =>
this.savedSearchesService.getSavedSearches().pipe(
this.savedSearchesService.savedSearches$.pipe(
first(),
map((savedSearches) => savedSearches.find((savedSearch) => savedSearch.encodedUrl === encodeURIComponent(params[this.queryParamName])))
)
@@ -224,18 +231,18 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
combineLatest([
this.route.queryParams,
this.router.events.pipe(
filter((e): e is NavigationStart => e instanceof NavigationStart),
filter((event): event is NavigationStart => event instanceof NavigationStart),
startWith(null)
)
])
.pipe(
takeUntilDestroyed(this.destroyRef),
tap(([params]) => {
this.queryBuilder.userQuery = '';
this.encodedQuery = params[this.queryParamName];
this.isLoading = !!this.encodedQuery;
this.searchedWord = extractSearchedWordFromEncodedQuery(this.encodedQuery);
this.updateUserQuery();
const filtersFromEncodedQuery = extractFiltersFromEncodedQuery(this.encodedQuery);
this.queryBuilder.populateFilters.next(filtersFromEncodedQuery || {});
@@ -355,11 +362,6 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
});
}
private updateUserQuery(): void {
const updatedUserQuery = formatSearchTerm(this.searchedWord, this.searchConfig['app:fields']);
this.queryBuilder.userQuery = updatedUserQuery;
}
private shouldExecuteQuery(navigationStartEvent: NavigationStart | null, query: string | undefined): boolean {
const hasQueryChanged = query !== this.previousEncodedQuery;
this.previousEncodedQuery = query;