Skip to content

Commit 99b2d4d

Browse files
committed
WEB-896: Add debounced dynamic search to client list
1 parent b541079 commit 99b2d4d

3 files changed

Lines changed: 212 additions & 6 deletions

File tree

src/app/clients/clients.component.html

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,21 @@
1414
matInput
1515
placeholder="{{ 'labels.text.SearchByClient' | translate }}"
1616
class="search-box"
17-
(keydown.enter)="search($event.target.value)"
17+
#searchInput
18+
(input)="onSearchInput(searchInput.value)"
19+
(keydown.enter)="search(searchInput.value)"
20+
(compositionstart)="onCompositionStart()"
21+
(compositionend)="onCompositionEnd(searchInput.value)"
1822
/>
23+
<button
24+
type="button"
25+
mat-icon-button
26+
matSuffix
27+
(click)="search(searchInput.value)"
28+
[attr.aria-label]="'labels.buttons.Search' | translate"
29+
>
30+
<mat-icon>search</mat-icon>
31+
</button>
1932
</mat-form-field>
2033
</div>
2134
@if (existsClientsToFilter) {
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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, DEBOUNCE_MS } 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 emptyPage = { content: [] as any[], totalElements: 0, numberOfElements: 0 };
27+
28+
beforeEach(async () => {
29+
jest.useFakeTimers();
30+
31+
clientsService = {
32+
searchByText: jest.fn(() => of(emptyPage))
33+
} as any;
34+
35+
const authService = { getCredentials: jest.fn(() => ({ permissions: ['ALL_FUNCTIONS'] })) } as any;
36+
37+
await TestBed.configureTestingModule({
38+
imports: [
39+
ClientsComponent,
40+
TranslateModule.forRoot()
41+
],
42+
providers: [
43+
{ provide: ClientsService, useValue: clientsService },
44+
{ provide: AuthenticationService, useValue: authService },
45+
provideAnimationsAsync(),
46+
provideRouter([])
47+
]
48+
}).compileComponents();
49+
50+
TestBed.inject(FaIconLibrary).addIcons(faDownload, faPlus, faStop);
51+
52+
fixture = TestBed.createComponent(ClientsComponent);
53+
component = fixture.componentInstance;
54+
fixture.detectChanges();
55+
56+
// Reset call count from ngOnInit (preloadClients may trigger a call)
57+
clientsService.searchByText.mockClear();
58+
});
59+
60+
afterEach(() => {
61+
jest.useRealTimers();
62+
});
63+
64+
it('should not search before 500 ms have elapsed', () => {
65+
component.onSearchInput('amara');
66+
jest.advanceTimersByTime(DEBOUNCE_MS - 1);
67+
expect(clientsService.searchByText).not.toHaveBeenCalled();
68+
});
69+
70+
it('should search after 500 ms pause', () => {
71+
component.onSearchInput('amara');
72+
jest.advanceTimersByTime(DEBOUNCE_MS);
73+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
74+
expect(clientsService.searchByText).toHaveBeenCalledWith('amara', 0, expect.any(Number), '', '');
75+
});
76+
77+
it('should reset the timer on rapid typing and fire only once', () => {
78+
component.onSearchInput('k');
79+
jest.advanceTimersByTime(200);
80+
component.onSearchInput('ka');
81+
jest.advanceTimersByTime(200);
82+
component.onSearchInput('kwame');
83+
jest.advanceTimersByTime(DEBOUNCE_MS);
84+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
85+
expect(clientsService.searchByText).toHaveBeenCalledWith('kwame', 0, expect.any(Number), '', '');
86+
});
87+
88+
it('should ignore duplicate consecutive values', () => {
89+
component.onSearchInput('agaba');
90+
jest.advanceTimersByTime(DEBOUNCE_MS);
91+
component.onSearchInput('agaba');
92+
jest.advanceTimersByTime(DEBOUNCE_MS);
93+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
94+
});
95+
96+
it('should search immediately when Enter is pressed', () => {
97+
component.search('bob');
98+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
99+
expect(clientsService.searchByText).toHaveBeenCalledWith('bob', 0, expect.any(Number), '', '');
100+
});
101+
102+
it('should not fire a second request when Enter is pressed while debounce is pending', () => {
103+
component.onSearchInput('kofi');
104+
jest.advanceTimersByTime(200);
105+
component.search('kofi');
106+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
107+
jest.advanceTimersByTime(DEBOUNCE_MS);
108+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
109+
});
110+
111+
it('should search correctly with non-ASCII characters', () => {
112+
component.onSearchInput('مريم');
113+
jest.advanceTimersByTime(DEBOUNCE_MS);
114+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
115+
expect(clientsService.searchByText).toHaveBeenCalledWith('مريم', 0, expect.any(Number), '', '');
116+
});
117+
118+
it('should not search during IME composition but should search on compositionend', () => {
119+
const inputEl: HTMLInputElement = fixture.nativeElement.querySelector('input[matInput]');
120+
121+
inputEl.dispatchEvent(new CompositionEvent('compositionstart'));
122+
fixture.detectChanges();
123+
124+
// Partial composition — input fires but should be suppressed
125+
inputEl.value = 'مر';
126+
inputEl.dispatchEvent(new Event('input'));
127+
jest.advanceTimersByTime(DEBOUNCE_MS);
128+
expect(clientsService.searchByText).not.toHaveBeenCalled();
129+
130+
// Composition ends with final value
131+
inputEl.value = 'مريم';
132+
inputEl.dispatchEvent(new CompositionEvent('compositionend'));
133+
fixture.detectChanges();
134+
135+
jest.advanceTimersByTime(DEBOUNCE_MS);
136+
expect(clientsService.searchByText).toHaveBeenCalledTimes(1);
137+
expect(clientsService.searchByText).toHaveBeenCalledWith('مريم', 0, expect.any(Number), '', '');
138+
});
139+
140+
it('should not fire debounced search after component is destroyed', () => {
141+
component.onSearchInput('carol');
142+
component.ngOnDestroy();
143+
jest.advanceTimersByTime(DEBOUNCE_MS);
144+
expect(clientsService.searchByText).not.toHaveBeenCalled();
145+
});
146+
});

src/app/clients/clients.component.ts

Lines changed: 52 additions & 5 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';
@@ -36,6 +42,8 @@ import { ExternalIdentifierComponent } from '../shared/external-identifier/exter
3642
import { StatusLookupPipe } from '../pipes/status-lookup.pipe';
3743
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
3844

45+
export const DEBOUNCE_MS = 500;
46+
3947
@Component({
4048
selector: 'mifosx-clients',
4149
templateUrl: './clients.component.html',
@@ -61,12 +69,19 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
6169
MatRowDef,
6270
MatRow,
6371
MatPaginator,
64-
StatusLookupPipe
72+
StatusLookupPipe,
73+
MatIconButton,
74+
MatIcon
6575
]
6676
})
67-
export class ClientsComponent implements OnInit {
77+
export class ClientsComponent implements OnInit, OnDestroy {
6878
private clientService = inject(ClientsService);
6979

80+
private destroy$ = new Subject<void>();
81+
private searchInput$ = new Subject<string>();
82+
private clientsRequestSub: Subscription | null = null;
83+
private isComposing = false;
84+
7085
/** Returns true if client data masking is enabled */
7186
get hideClientData(): boolean {
7287
return environment.complianceHideClientData;
@@ -109,23 +124,55 @@ export class ClientsComponent implements OnInit {
109124
@ViewChild(MatSort) sort: MatSort;
110125

111126
ngOnInit() {
127+
this.searchInput$
128+
.pipe(debounceTime(DEBOUNCE_MS), distinctUntilChanged(), takeUntil(this.destroy$))
129+
.subscribe((value) => {
130+
if (value !== this.filterText) {
131+
this.search(value);
132+
}
133+
});
134+
112135
if (environment.preloadClients) {
113136
this.getClients();
114137
}
115138
}
116139

140+
ngOnDestroy() {
141+
this.clientsRequestSub?.unsubscribe();
142+
this.destroy$.next();
143+
this.destroy$.complete();
144+
}
145+
146+
onSearchInput(value: string) {
147+
if (this.isComposing) return;
148+
this.searchInput$.next(value);
149+
}
150+
151+
onCompositionStart(): void {
152+
this.isComposing = true;
153+
}
154+
155+
onCompositionEnd(value: string): void {
156+
this.isComposing = false;
157+
this.searchInput$.next(value);
158+
}
159+
117160
/**
118161
* Searches server for query and resource.
119162
*/
120163
search(value: string) {
121164
this.filterText = value;
122-
this.resetPaginator();
165+
if (this.paginator?.pageIndex !== 0) {
166+
this.resetPaginator();
167+
return;
168+
}
123169
this.getClients();
124170
}
125171

126172
private getClients() {
173+
this.clientsRequestSub?.unsubscribe();
127174
this.isLoading = true;
128-
this.clientService
175+
this.clientsRequestSub = this.clientService
129176
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
130177
.subscribe(
131178
(data: any) => {

0 commit comments

Comments
 (0)