Skip to content

Commit fae10ee

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

3 files changed

Lines changed: 161 additions & 3 deletions

File tree

src/app/clients/clients.component.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,18 @@
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+
mat-icon-button
23+
matSuffix
24+
(click)="search(searchInput.value)"
25+
[attr.aria-label]="'labels.buttons.Search' | translate"
26+
>
27+
<mat-icon>search</mat-icon>
28+
</button>
1929
</mat-form-field>
2030
</div>
2131
@if (existsClientsToFilter) {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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 * as solidIcons 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+
const faIconLibrary = TestBed.inject(FaIconLibrary);
52+
const iconList = Object.keys(solidIcons)
53+
.filter((key) => key !== 'fas' && key !== 'prefix' && key.startsWith('fa'))
54+
.map((icon) => (solidIcons as any)[icon]);
55+
faIconLibrary.addIcons(...iconList);
56+
57+
fixture = TestBed.createComponent(ClientsComponent);
58+
component = fixture.componentInstance;
59+
fixture.detectChanges();
60+
61+
// Reset call count from ngOnInit (preloadClients may trigger a call)
62+
clientsService.searchByText.mockClear();
63+
});
64+
65+
afterEach(() => {
66+
jest.useRealTimers();
67+
});
68+
69+
it('should not search before 500 ms have elapsed', () => {
70+
component.onSearchInput('alice');
71+
jest.advanceTimersByTime(DEBOUNCE_MS - 1);
72+
expect(clientsService.searchByText).not.toHaveBeenCalled();
73+
});
74+
75+
it('should search after 500 ms pause', () => {
76+
component.onSearchInput('alice');
77+
jest.advanceTimersByTime(DEBOUNCE_MS);
78+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
79+
expect(clientsService.searchByText).toHaveBeenCalledWith('alice', 0, expect.any(Number), '', '');
80+
});
81+
82+
it('should reset the timer on rapid typing and fire only once', () => {
83+
component.onSearchInput('a');
84+
jest.advanceTimersByTime(200);
85+
component.onSearchInput('al');
86+
jest.advanceTimersByTime(200);
87+
component.onSearchInput('ali');
88+
jest.advanceTimersByTime(DEBOUNCE_MS);
89+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
90+
expect(clientsService.searchByText).toHaveBeenCalledWith('ali', 0, expect.any(Number), '', '');
91+
});
92+
93+
it('should ignore duplicate consecutive values', () => {
94+
component.onSearchInput('alice');
95+
jest.advanceTimersByTime(DEBOUNCE_MS);
96+
component.onSearchInput('alice');
97+
jest.advanceTimersByTime(DEBOUNCE_MS);
98+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
99+
});
100+
101+
it('should search immediately when Enter is pressed', () => {
102+
component.search('bob');
103+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
104+
expect(clientsService.searchByText).toHaveBeenCalledWith('bob', 0, expect.any(Number), '', '');
105+
});
106+
107+
it('should not fire a second request when Enter is pressed while debounce is pending', () => {
108+
component.onSearchInput('dave');
109+
jest.advanceTimersByTime(200);
110+
component.search('dave');
111+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
112+
jest.advanceTimersByTime(DEBOUNCE_MS);
113+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
114+
});
115+
116+
it('should not fire debounced search after component is destroyed', () => {
117+
component.onSearchInput('carol');
118+
component.ngOnDestroy();
119+
jest.advanceTimersByTime(DEBOUNCE_MS);
120+
expect(clientsService.searchByText).not.toHaveBeenCalled();
121+
});
122+
});

src/app/clients/clients.component.ts

Lines changed: 29 additions & 3 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 } from 'rxjs';
32+
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
2733

2834
/** Custom Services */
2935
import { environment } from '../../environments/environment';
@@ -61,12 +67,17 @@ 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+
7081
/** Returns true if client data masking is enabled */
7182
get hideClientData(): boolean {
7283
return environment.complianceHideClientData;
@@ -109,11 +120,26 @@ export class ClientsComponent implements OnInit {
109120
@ViewChild(MatSort) sort: MatSort;
110121

111122
ngOnInit() {
123+
this.searchInput$.pipe(debounceTime(500), distinctUntilChanged(), takeUntil(this.destroy$)).subscribe((value) => {
124+
if (value !== this.filterText) {
125+
this.search(value);
126+
}
127+
});
128+
112129
if (environment.preloadClients) {
113130
this.getClients();
114131
}
115132
}
116133

134+
ngOnDestroy() {
135+
this.destroy$.next();
136+
this.destroy$.complete();
137+
}
138+
139+
onSearchInput(value: string) {
140+
this.searchInput$.next(value);
141+
}
142+
117143
/**
118144
* Searches server for query and resource.
119145
*/

0 commit comments

Comments
 (0)