Skip to content

Commit fc7c839

Browse files
committed
feat: fire onSearchChange immediately on clear, add debounce integration tests
CampaignsTable now calls onSearchChange('') synchronously when the search input is cleared, bypassing the 300ms debounce. Typing still debounces normally via useDebounce. Adds Vitest tests that verify both behaviors using fake timers. Closes #254
1 parent 1beddc1 commit fc7c839

2 files changed

Lines changed: 127 additions & 2 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Integration tests verifying that CampaignsTable debounces the onSearchChange
3+
* callback and fires immediately on clear (#254).
4+
*/
5+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6+
import { render, screen, act, fireEvent } from '@testing-library/react';
7+
import { MemoryRouter } from 'react-router-dom';
8+
import { CampaignsTable } from './CampaignsTable';
9+
import type { Campaign } from '../types/campaign';
10+
11+
// jsdom does not implement matchMedia
12+
Object.defineProperty(window, 'matchMedia', {
13+
writable: true,
14+
value: vi.fn().mockImplementation((query: string) => ({
15+
matches: false,
16+
media: query,
17+
onchange: null,
18+
addListener: vi.fn(),
19+
removeListener: vi.fn(),
20+
addEventListener: vi.fn(),
21+
removeEventListener: vi.fn(),
22+
dispatchEvent: vi.fn(),
23+
})),
24+
});
25+
26+
const STUB_CAMPAIGN: Campaign = {
27+
id: 'c1',
28+
creator: 'G' + 'A'.repeat(55),
29+
title: 'Test Campaign',
30+
description: 'desc',
31+
acceptedTokens: ['XLM'],
32+
assetCode: 'XLM',
33+
targetAmount: 1000,
34+
pledgedAmount: 0,
35+
deadline: Date.now() / 1000 + 86400,
36+
createdAt: Date.now() / 1000,
37+
progress: { percentage: 0, pledgedAmount: 0, targetAmount: 1000 },
38+
};
39+
40+
function renderTable(onSearchChange: (q: string) => void) {
41+
return render(
42+
<MemoryRouter>
43+
<CampaignsTable
44+
campaigns={[STUB_CAMPAIGN]}
45+
selectedCampaignId={null}
46+
onSelect={vi.fn()}
47+
isLoading={false}
48+
hasMore={false}
49+
onSearchChange={onSearchChange}
50+
/>
51+
</MemoryRouter>,
52+
);
53+
}
54+
55+
describe('CampaignsTable debounced search (#254)', () => {
56+
beforeEach(() => {
57+
vi.useFakeTimers();
58+
});
59+
60+
afterEach(() => {
61+
vi.runOnlyPendingTimers();
62+
vi.useRealTimers();
63+
});
64+
65+
it('does not call onSearchChange before 300 ms elapses', () => {
66+
const onSearchChange = vi.fn();
67+
renderTable(onSearchChange);
68+
69+
const input = screen.getByRole('textbox', { name: /search campaigns/i });
70+
fireEvent.change(input, { target: { value: 'ro' } });
71+
72+
// Timer not advanced — debounce window not expired
73+
const nonEmptyCalls = onSearchChange.mock.calls.filter(([v]) => v !== '');
74+
expect(nonEmptyCalls).toHaveLength(0);
75+
});
76+
77+
it('calls onSearchChange exactly once after 300 ms pause', async () => {
78+
const onSearchChange = vi.fn();
79+
renderTable(onSearchChange);
80+
81+
const input = screen.getByRole('textbox', { name: /search campaigns/i });
82+
fireEvent.change(input, { target: { value: 'r' } });
83+
fireEvent.change(input, { target: { value: 'ro' } });
84+
fireEvent.change(input, { target: { value: 'roc' } });
85+
fireEvent.change(input, { target: { value: 'rock' } });
86+
87+
// No callback yet
88+
expect(onSearchChange).not.toHaveBeenCalledWith('rock');
89+
90+
// Advance past the debounce delay
91+
await act(async () => { vi.advanceTimersByTime(300); });
92+
93+
const rocketCalls = onSearchChange.mock.calls.filter(([v]) => v === 'rock');
94+
expect(rocketCalls).toHaveLength(1);
95+
// Not called for each intermediate keystroke
96+
const allNonEmpty = onSearchChange.mock.calls.filter(([v]) => v !== '');
97+
expect(allNonEmpty.length).toBeLessThanOrEqual(1);
98+
});
99+
100+
it('fires onSearchChange immediately when input is cleared', async () => {
101+
const onSearchChange = vi.fn();
102+
renderTable(onSearchChange);
103+
104+
const input = screen.getByRole('textbox', { name: /search campaigns/i });
105+
fireEvent.change(input, { target: { value: 'abc' } });
106+
await act(async () => { vi.advanceTimersByTime(300); });
107+
onSearchChange.mockClear();
108+
109+
// Simulate clear button click by changing value to empty string
110+
fireEvent.change(input, { target: { value: '' } });
111+
112+
// Should have fired immediately (handleSearchChange calls onSearchChange('') directly)
113+
expect(onSearchChange).toHaveBeenCalledWith('');
114+
expect(onSearchChange).toHaveBeenCalledTimes(1);
115+
});
116+
});

frontend/src/components/CampaignsTable.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,17 @@ export function CampaignsTable({
9696
onSortChange?.(newSort, newOrder);
9797
}
9898

99+
function handleSearchChange(value: string) {
100+
setSearchQuery(value);
101+
if (value === '') {
102+
onSearchChange?.('');
103+
}
104+
}
105+
99106
useEffect(() => {
100-
onSearchChange?.(debouncedSearchQuery);
107+
if (debouncedSearchQuery !== '') {
108+
onSearchChange?.(debouncedSearchQuery);
109+
}
101110
}, [debouncedSearchQuery, onSearchChange]);
102111

103112
useEffect(() => {
@@ -203,7 +212,7 @@ export function CampaignsTable({
203212
<div className="board-controls">
204213
<SearchInput
205214
value={searchQuery}
206-
onChange={setSearchQuery}
215+
onChange={handleSearchChange}
207216
disabled={isLoading}
208217
/>
209218
<label className="field-group" style={{ minWidth: 180 }}>

0 commit comments

Comments
 (0)