Skip to content

Commit e052b42

Browse files
committed
WEB-896: Add debounced dynamic search to client list
1 parent f82ad09 commit e052b42

3 files changed

Lines changed: 169 additions & 4 deletions

File tree

src/app/clients/clients.component.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,19 @@
1414
matInput
1515
placeholder="{{ 'labels.text.SearchByClient' | translate }}"
1616
class="search-box"
17+
#searchInput
18+
(input)="onSearchInput($event.target.value)"
1719
(keydown.enter)="search($event.target.value)"
1820
/>
21+
<button
22+
type="button"
23+
mat-icon-button
24+
matSuffix
25+
(click)="search(searchInput.value)"
26+
[attr.aria-label]="'labels.buttons.Search' | translate"
27+
>
28+
<mat-icon>search</mat-icon>
29+
</button>
1930
</mat-form-field>
2031
</div>
2132
@if (existsClientsToFilter) {
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { ComponentFixture, TestBed } from '@angular/core/testing';
10+
import { of } from 'rxjs';
11+
import { ClientsComponent } from './clients.component';
12+
import { ClientsService } from './clients.service';
13+
import { AuthenticationService } from 'app/core/authentication/authentication.service';
14+
import { TranslateModule } from '@ngx-translate/core';
15+
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
16+
import { provideRouter } from '@angular/router';
17+
import { FaIconLibrary } from '@fortawesome/angular-fontawesome';
18+
import { faDownload, faPlus, faStop } from '@fortawesome/free-solid-svg-icons';
19+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
20+
21+
describe('ClientsComponent — debounce search', () => {
22+
let component: ClientsComponent;
23+
let fixture: ComponentFixture<ClientsComponent>;
24+
let clientsService: jest.Mocked<ClientsService>;
25+
26+
const DEBOUNCE_MS = 500;
27+
const emptyPage = { content: [] as any[], totalElements: 0, numberOfElements: 0 };
28+
29+
beforeEach(async () => {
30+
jest.useFakeTimers();
31+
32+
clientsService = {
33+
searchByText: jest.fn(() => of(emptyPage))
34+
} as any;
35+
36+
const authService = { getCredentials: jest.fn(() => ({ permissions: ['ALL_FUNCTIONS'] })) } as any;
37+
38+
await TestBed.configureTestingModule({
39+
imports: [
40+
ClientsComponent,
41+
TranslateModule.forRoot()
42+
],
43+
providers: [
44+
{ provide: ClientsService, useValue: clientsService },
45+
{ provide: AuthenticationService, useValue: authService },
46+
provideAnimationsAsync(),
47+
provideRouter([])
48+
]
49+
}).compileComponents();
50+
51+
TestBed.inject(FaIconLibrary).addIcons(faDownload, faPlus, faStop);
52+
53+
fixture = TestBed.createComponent(ClientsComponent);
54+
component = fixture.componentInstance;
55+
fixture.detectChanges();
56+
57+
// Reset call count from ngOnInit (preloadClients may trigger a call)
58+
clientsService.searchByText.mockClear();
59+
});
60+
61+
afterEach(() => {
62+
jest.useRealTimers();
63+
});
64+
65+
it('should not search before 500 ms have elapsed', () => {
66+
component.onSearchInput('amara');
67+
jest.advanceTimersByTime(DEBOUNCE_MS - 1);
68+
expect(clientsService.searchByText).not.toHaveBeenCalled();
69+
});
70+
71+
it('should search after 500 ms pause', () => {
72+
component.onSearchInput('amara');
73+
jest.advanceTimersByTime(DEBOUNCE_MS);
74+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
75+
expect(clientsService.searchByText).toHaveBeenCalledWith('amara', 0, expect.any(Number), '', '');
76+
});
77+
78+
it('should reset the timer on rapid typing and fire only once', () => {
79+
component.onSearchInput('k');
80+
jest.advanceTimersByTime(200);
81+
component.onSearchInput('ka');
82+
jest.advanceTimersByTime(200);
83+
component.onSearchInput('kwame');
84+
jest.advanceTimersByTime(DEBOUNCE_MS);
85+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
86+
expect(clientsService.searchByText).toHaveBeenCalledWith('kwame', 0, expect.any(Number), '', '');
87+
});
88+
89+
it('should ignore duplicate consecutive values', () => {
90+
component.onSearchInput('agaba');
91+
jest.advanceTimersByTime(DEBOUNCE_MS);
92+
component.onSearchInput('agaba');
93+
jest.advanceTimersByTime(DEBOUNCE_MS);
94+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
95+
});
96+
97+
it('should search immediately when Enter is pressed', () => {
98+
component.search('bob');
99+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
100+
expect(clientsService.searchByText).toHaveBeenCalledWith('bob', 0, expect.any(Number), '', '');
101+
});
102+
103+
it('should not fire a second request when Enter is pressed while debounce is pending', () => {
104+
component.onSearchInput('kofi');
105+
jest.advanceTimersByTime(200);
106+
component.search('kofi');
107+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
108+
jest.advanceTimersByTime(DEBOUNCE_MS);
109+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
110+
});
111+
112+
it('should search correctly with non-ASCII characters', () => {
113+
component.onSearchInput('مريم');
114+
jest.advanceTimersByTime(DEBOUNCE_MS);
115+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
116+
expect(clientsService.searchByText).toHaveBeenCalledWith('مريم', 0, expect.any(Number), '', '');
117+
});
118+
119+
it('should not fire debounced search after component is destroyed', () => {
120+
component.onSearchInput('carol');
121+
component.ngOnDestroy();
122+
jest.advanceTimersByTime(DEBOUNCE_MS);
123+
expect(clientsService.searchByText).not.toHaveBeenCalled();
124+
});
125+
});

src/app/clients/clients.component.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
/** Angular Imports. */
10-
import { Component, OnInit, ViewChild, inject } from '@angular/core';
10+
import { Component, OnInit, OnDestroy, ViewChild, inject } from '@angular/core';
1111
import { MatCheckbox } from '@angular/material/checkbox';
1212
import { MatPaginator, PageEvent } from '@angular/material/paginator';
1313
import { MatSort, Sort, MatSortHeader } from '@angular/material/sort';
@@ -24,6 +24,12 @@ import {
2424
MatRowDef,
2525
MatRow
2626
} from '@angular/material/table';
27+
import { MatIconButton } from '@angular/material/button';
28+
import { MatIcon } from '@angular/material/icon';
29+
30+
/** rxjs Imports */
31+
import { Subject, Subscription } from 'rxjs';
32+
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
2733

2834
/** Custom Services */
2935
import { environment } from '../../environments/environment';
@@ -61,12 +67,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
6167
MatRowDef,
6268
MatRow,
6369
MatPaginator,
64-
StatusLookupPipe
70+
StatusLookupPipe,
71+
MatIconButton,
72+
MatIcon
6573
]
6674
})
67-
export class ClientsComponent implements OnInit {
75+
export class ClientsComponent implements OnInit, OnDestroy {
6876
private clientService = inject(ClientsService);
6977

78+
private destroy$ = new Subject<void>();
79+
private searchInput$ = new Subject<string>();
80+
private clientsRequestSub: Subscription | null = null;
81+
7082
/** Returns true if client data masking is enabled */
7183
get hideClientData(): boolean {
7284
return environment.complianceHideClientData;
@@ -109,11 +121,27 @@ export class ClientsComponent implements OnInit {
109121
@ViewChild(MatSort) sort: MatSort;
110122

111123
ngOnInit() {
124+
this.searchInput$.pipe(debounceTime(500), distinctUntilChanged(), takeUntil(this.destroy$)).subscribe((value) => {
125+
if (value !== this.filterText) {
126+
this.search(value);
127+
}
128+
});
129+
112130
if (environment.preloadClients) {
113131
this.getClients();
114132
}
115133
}
116134

135+
ngOnDestroy() {
136+
this.clientsRequestSub?.unsubscribe();
137+
this.destroy$.next();
138+
this.destroy$.complete();
139+
}
140+
141+
onSearchInput(value: string) {
142+
this.searchInput$.next(value);
143+
}
144+
117145
/**
118146
* Searches server for query and resource.
119147
*/
@@ -124,8 +152,9 @@ export class ClientsComponent implements OnInit {
124152
}
125153

126154
private getClients() {
155+
this.clientsRequestSub?.unsubscribe();
127156
this.isLoading = true;
128-
this.clientService
157+
this.clientsRequestSub = this.clientService
129158
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
130159
.subscribe(
131160
(data: any) => {

0 commit comments

Comments
 (0)