Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/app/clients/clients.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,21 @@
matInput
placeholder="{{ 'labels.text.SearchByClient' | translate }}"
class="search-box"
(keydown.enter)="search($event.target.value)"
#searchInput
(input)="onSearchInput(searchInput.value)"
(keydown.enter)="search(searchInput.value)"
(compositionstart)="onCompositionStart()"
(compositionend)="onCompositionEnd(searchInput.value)"
/>
<button
type="button"
mat-icon-button
matSuffix
(click)="search(searchInput.value)"
[attr.aria-label]="'labels.buttons.Search' | translate"
>
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
</div>
@if (existsClientsToFilter) {
Expand Down
146 changes: 146 additions & 0 deletions src/app/clients/clients.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ClientsComponent, DEBOUNCE_MS } from './clients.component';
import { ClientsService } from './clients.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { TranslateModule } from '@ngx-translate/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter } from '@angular/router';
import { FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { faDownload, faPlus, faStop } from '@fortawesome/free-solid-svg-icons';
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';

describe('ClientsComponent — debounce search', () => {
let component: ClientsComponent;
let fixture: ComponentFixture<ClientsComponent>;
let clientsService: jest.Mocked<ClientsService>;

const emptyPage = { content: [] as any[], totalElements: 0, numberOfElements: 0 };

beforeEach(async () => {
jest.useFakeTimers();

clientsService = {
searchByText: jest.fn(() => of(emptyPage))
} as any;

const authService = { getCredentials: jest.fn(() => ({ permissions: ['ALL_FUNCTIONS'] })) } as any;

await TestBed.configureTestingModule({
imports: [
ClientsComponent,
TranslateModule.forRoot()
],
providers: [
{ provide: ClientsService, useValue: clientsService },
{ provide: AuthenticationService, useValue: authService },
provideAnimationsAsync(),
provideRouter([])
]
}).compileComponents();

TestBed.inject(FaIconLibrary).addIcons(faDownload, faPlus, faStop);

fixture = TestBed.createComponent(ClientsComponent);
component = fixture.componentInstance;
fixture.detectChanges();

// Reset call count from ngOnInit (preloadClients may trigger a call)
clientsService.searchByText.mockClear();
});

afterEach(() => {
jest.useRealTimers();
});

it('should not search before 500 ms have elapsed', () => {
component.onSearchInput('amara');
jest.advanceTimersByTime(DEBOUNCE_MS - 1);
expect(clientsService.searchByText).not.toHaveBeenCalled();
});

it('should search after 500 ms pause', () => {
component.onSearchInput('amara');
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
expect(clientsService.searchByText).toHaveBeenCalledWith('amara', 0, expect.any(Number), '', '');
});

it('should reset the timer on rapid typing and fire only once', () => {
component.onSearchInput('k');
jest.advanceTimersByTime(200);
component.onSearchInput('ka');
jest.advanceTimersByTime(200);
component.onSearchInput('kwame');
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
expect(clientsService.searchByText).toHaveBeenCalledWith('kwame', 0, expect.any(Number), '', '');
});

it('should ignore duplicate consecutive values', () => {
component.onSearchInput('agaba');
jest.advanceTimersByTime(DEBOUNCE_MS);
component.onSearchInput('agaba');
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
});

it('should search immediately when Enter is pressed', () => {
component.search('bob');
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
expect(clientsService.searchByText).toHaveBeenCalledWith('bob', 0, expect.any(Number), '', '');
});

it('should not fire a second request when Enter is pressed while debounce is pending', () => {
component.onSearchInput('kofi');
jest.advanceTimersByTime(200);
component.search('kofi');
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
});

it('should search correctly with non-ASCII characters', () => {
component.onSearchInput('مريم');
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
expect(clientsService.searchByText).toHaveBeenCalledWith('مريم', 0, expect.any(Number), '', '');
});

it('should not search during IME composition but should search on compositionend', () => {
const inputEl: HTMLInputElement = fixture.nativeElement.querySelector('input[matInput]');

inputEl.dispatchEvent(new CompositionEvent('compositionstart'));
fixture.detectChanges();

// Partial composition — input fires but should be suppressed
inputEl.value = 'مر';
inputEl.dispatchEvent(new Event('input'));
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(clientsService.searchByText).not.toHaveBeenCalled();

// Composition ends with final value
inputEl.value = 'مريم';
inputEl.dispatchEvent(new CompositionEvent('compositionend'));
fixture.detectChanges();

jest.advanceTimersByTime(DEBOUNCE_MS);
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
expect(clientsService.searchByText).toHaveBeenCalledWith('مريم', 0, expect.any(Number), '', '');
});

it('should not fire debounced search after component is destroyed', () => {
component.onSearchInput('carol');
component.ngOnDestroy();
jest.advanceTimersByTime(DEBOUNCE_MS);
expect(clientsService.searchByText).not.toHaveBeenCalled();
});
});
57 changes: 52 additions & 5 deletions src/app/clients/clients.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

/** Angular Imports. */
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { Component, OnInit, OnDestroy, ViewChild, inject } from '@angular/core';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort, MatSortHeader } from '@angular/material/sort';
Expand All @@ -24,6 +24,12 @@ import {
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';

/** rxjs Imports */
import { Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';

/** Custom Services */
import { environment } from '../../environments/environment';
Expand All @@ -36,6 +42,8 @@ import { ExternalIdentifierComponent } from '../shared/external-identifier/exter
import { StatusLookupPipe } from '../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';

export const DEBOUNCE_MS = 500;

@Component({
selector: 'mifosx-clients',
templateUrl: './clients.component.html',
Expand All @@ -61,12 +69,19 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRowDef,
MatRow,
MatPaginator,
StatusLookupPipe
StatusLookupPipe,
MatIconButton,
MatIcon
]
})
export class ClientsComponent implements OnInit {
export class ClientsComponent implements OnInit, OnDestroy {
private clientService = inject(ClientsService);

private destroy$ = new Subject<void>();
private searchInput$ = new Subject<string>();
private clientsRequestSub: Subscription | null = null;
private isComposing = false;

/** Returns true if client data masking is enabled */
get hideClientData(): boolean {
return environment.complianceHideClientData;
Expand Down Expand Up @@ -109,23 +124,55 @@ export class ClientsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort;

ngOnInit() {
this.searchInput$
.pipe(debounceTime(DEBOUNCE_MS), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((value) => {
if (value !== this.filterText) {
this.search(value);
}
});

if (environment.preloadClients) {
this.getClients();
}
}

ngOnDestroy() {
this.clientsRequestSub?.unsubscribe();
this.destroy$.next();
this.destroy$.complete();
}

onSearchInput(value: string) {
if (this.isComposing) return;
this.searchInput$.next(value);
}

onCompositionStart(): void {
this.isComposing = true;
}

onCompositionEnd(value: string): void {
this.isComposing = false;
this.searchInput$.next(value);
}

/**
* Searches server for query and resource.
*/
search(value: string) {
this.filterText = value;
this.resetPaginator();
if (this.paginator?.pageIndex !== 0) {
this.resetPaginator();
return;
}
this.getClients();
}

private getClients() {
this.clientsRequestSub?.unsubscribe();
this.isLoading = true;
this.clientService
this.clientsRequestSub = this.clientService
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
.subscribe(
(data: any) => {
Expand Down
Loading