Skip to content

Commit c24d3e7

Browse files
author
Test User
committed
fix(ui): keep filter buttons visible when BlockerPanel filter returns empty results
Filter buttons (All/SYNC/ASYNC) were disappearing when the selected type filter returned 0 results, leaving users unable to switch back to other filters. - Add allPendingBlockers memo to track total pending count before type filtering - Distinguish between "truly empty" (no pending blockers) and "filtered empty" - Show filter buttons with helpful message when filter returns 0 but blockers exist - Extract FilterButtons component to eliminate code duplication - Add 6 new tests covering type filtering edge cases
1 parent 2e66ed3 commit c24d3e7

File tree

2 files changed

+190
-40
lines changed

2 files changed

+190
-40
lines changed

web-ui/__tests__/components/BlockerPanel.test.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,114 @@ describe('BlockerPanel', () => {
311311
});
312312
});
313313

314+
describe('type filtering', () => {
315+
it('keeps filter buttons visible when ASYNC filter returns 0 results', () => {
316+
// Render with only SYNC blockers
317+
render(<BlockerPanel blockers={[mockSyncBlocker]} />);
318+
319+
// Click ASYNC filter
320+
fireEvent.click(screen.getByRole('button', { name: 'ASYNC' }));
321+
322+
// All three filter buttons should still be visible
323+
expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
324+
expect(screen.getByRole('button', { name: 'SYNC' })).toBeInTheDocument();
325+
expect(screen.getByRole('button', { name: 'ASYNC' })).toBeInTheDocument();
326+
327+
// Count should show 0
328+
expect(screen.getByText('(0)')).toBeInTheDocument();
329+
330+
// Empty message should be displayed
331+
expect(screen.getByText('No ASYNC blockers found')).toBeInTheDocument();
332+
expect(screen.getByText('Try selecting a different filter')).toBeInTheDocument();
333+
});
334+
335+
it('keeps filter buttons visible when SYNC filter returns 0 results', () => {
336+
// Render with only ASYNC blockers
337+
render(<BlockerPanel blockers={[mockAsyncBlocker]} />);
338+
339+
// Click SYNC filter
340+
fireEvent.click(screen.getByRole('button', { name: 'SYNC' }));
341+
342+
// All three filter buttons should still be visible
343+
expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
344+
expect(screen.getByRole('button', { name: 'SYNC' })).toBeInTheDocument();
345+
expect(screen.getByRole('button', { name: 'ASYNC' })).toBeInTheDocument();
346+
347+
// Count should show 0
348+
expect(screen.getByText('(0)')).toBeInTheDocument();
349+
350+
// Empty message should be displayed
351+
expect(screen.getByText('No SYNC blockers found')).toBeInTheDocument();
352+
});
353+
354+
it('allows switching back to ALL filter after seeing empty filtered results', () => {
355+
// Render with only SYNC blockers
356+
render(<BlockerPanel blockers={[mockSyncBlocker]} />);
357+
358+
// Click ASYNC filter (should show 0 results)
359+
fireEvent.click(screen.getByRole('button', { name: 'ASYNC' }));
360+
expect(screen.getByText('(0)')).toBeInTheDocument();
361+
362+
// Click ALL filter
363+
fireEvent.click(screen.getByRole('button', { name: 'All' }));
364+
365+
// SYNC blocker should now be visible
366+
expect(screen.getByText(mockSyncBlocker.question)).toBeInTheDocument();
367+
368+
// Count should show 1
369+
expect(screen.getByText('(1)')).toBeInTheDocument();
370+
});
371+
372+
it('shows empty state without buttons only when truly no pending blockers', () => {
373+
// Render with only resolved/expired blockers
374+
render(<BlockerPanel blockers={[mockResolvedBlocker, mockExpiredBlocker]} />);
375+
376+
// Empty state message should be shown
377+
expect(screen.getByText('No blockers - agents are running smoothly!')).toBeInTheDocument();
378+
379+
// Filter buttons should NOT be present
380+
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
381+
expect(screen.queryByRole('button', { name: 'SYNC' })).not.toBeInTheDocument();
382+
expect(screen.queryByRole('button', { name: 'ASYNC' })).not.toBeInTheDocument();
383+
});
384+
385+
it('shows empty state without buttons when blockers array is empty', () => {
386+
render(<BlockerPanel blockers={mockEmptyBlockersList} />);
387+
388+
// Empty state message should be shown
389+
expect(screen.getByText('No blockers - agents are running smoothly!')).toBeInTheDocument();
390+
391+
// Filter buttons should NOT be present
392+
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
393+
});
394+
395+
it('switches between filter states correctly', () => {
396+
// Render with both SYNC and ASYNC blockers
397+
render(<BlockerPanel blockers={[mockSyncBlocker, mockAsyncBlocker]} />);
398+
399+
// Initially ALL filter, both visible
400+
expect(screen.getByText('(2)')).toBeInTheDocument();
401+
expect(screen.getByText(mockSyncBlocker.question)).toBeInTheDocument();
402+
expect(screen.getByText(mockAsyncBlocker.question)).toBeInTheDocument();
403+
404+
// Click SYNC filter
405+
fireEvent.click(screen.getByRole('button', { name: 'SYNC' }));
406+
expect(screen.getByText('(1)')).toBeInTheDocument();
407+
expect(screen.getByText(mockSyncBlocker.question)).toBeInTheDocument();
408+
expect(screen.queryByText(mockAsyncBlocker.question)).not.toBeInTheDocument();
409+
410+
// Click ASYNC filter
411+
fireEvent.click(screen.getByRole('button', { name: 'ASYNC' }));
412+
expect(screen.getByText('(1)')).toBeInTheDocument();
413+
expect(screen.queryByText(mockSyncBlocker.question)).not.toBeInTheDocument();
414+
expect(screen.getByText(mockAsyncBlocker.question)).toBeInTheDocument();
415+
416+
// Click ALL filter again
417+
fireEvent.click(screen.getByRole('button', { name: 'All' }));
418+
expect(screen.getByText('(2)')).toBeInTheDocument();
419+
});
420+
});
421+
314422
describe('UI styling and structure', () => {
315423
it('applies hover styles to blocker buttons', () => {
316424
render(<BlockerPanel blockers={[mockSyncBlocker]} />);

web-ui/src/components/BlockerPanel.tsx

Lines changed: 82 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,48 @@ function truncateText(text: string, maxLength: number): string {
3434
return text.substring(0, maxLength) + '...';
3535
}
3636

37+
interface FilterButtonsProps {
38+
filter: BlockerFilter;
39+
setFilter: (filter: BlockerFilter) => void;
40+
}
41+
42+
function FilterButtons({ filter, setFilter }: FilterButtonsProps) {
43+
return (
44+
<div className="flex gap-2">
45+
<button
46+
onClick={() => setFilter('all')}
47+
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
48+
filter === 'all'
49+
? 'bg-primary text-primary-foreground'
50+
: 'bg-muted text-foreground hover:bg-muted/80'
51+
}`}
52+
>
53+
All
54+
</button>
55+
<button
56+
onClick={() => setFilter('sync')}
57+
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
58+
filter === 'sync'
59+
? 'bg-destructive text-destructive-foreground'
60+
: 'bg-muted text-foreground hover:bg-muted/80'
61+
}`}
62+
>
63+
SYNC
64+
</button>
65+
<button
66+
onClick={() => setFilter('async')}
67+
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
68+
filter === 'async'
69+
? 'bg-accent text-accent-foreground'
70+
: 'bg-muted text-foreground hover:bg-muted/80'
71+
}`}
72+
>
73+
ASYNC
74+
</button>
75+
</div>
76+
);
77+
}
78+
3779
export default function BlockerPanel({ blockers, onBlockerClick }: BlockerPanelProps) {
3880
// Filter state (T068)
3981
const [filter, setFilter] = useState<BlockerFilter>('all');
@@ -50,22 +92,26 @@ export default function BlockerPanel({ blockers, onBlockerClick }: BlockerPanelP
5092
});
5193
}, [blockers]);
5294

95+
// All pending blockers (before type filter) - used for empty state decision
96+
const allPendingBlockers = useMemo(() => {
97+
return sortedBlockers.filter(b => b.status === 'PENDING');
98+
}, [sortedBlockers]);
99+
53100
// Filter for pending blockers only, then apply type filter (T068)
54101
const filteredBlockers = useMemo(() => {
55-
const pending = sortedBlockers.filter(b => b.status === 'PENDING');
56-
57102
if (filter === 'sync') {
58-
return pending.filter(b => b.blocker_type === 'SYNC');
103+
return allPendingBlockers.filter(b => b.blocker_type === 'SYNC');
59104
} else if (filter === 'async') {
60-
return pending.filter(b => b.blocker_type === 'ASYNC');
105+
return allPendingBlockers.filter(b => b.blocker_type === 'ASYNC');
61106
}
62-
return pending;
63-
}, [sortedBlockers, filter]);
107+
return allPendingBlockers;
108+
}, [allPendingBlockers, filter]);
64109

65110
// Alias for backwards compatibility
66111
const pendingBlockers = filteredBlockers;
67112

68-
if (pendingBlockers.length === 0) {
113+
// Truly empty state - no pending blockers at all (don't show filter buttons)
114+
if (allPendingBlockers.length === 0) {
69115
return (
70116
<div className="bg-card rounded-lg shadow p-4 border border-border">
71117
<h2 className="text-lg font-semibold mb-3 text-foreground">
@@ -79,6 +125,34 @@ export default function BlockerPanel({ blockers, onBlockerClick }: BlockerPanelP
79125
);
80126
}
81127

128+
// Get filter display name for empty message
129+
const filterDisplayName = filter === 'sync' ? 'SYNC' : filter === 'async' ? 'ASYNC' : 'All';
130+
131+
// Filtered empty state - pending blockers exist but none match current filter
132+
if (filteredBlockers.length === 0) {
133+
return (
134+
<div className="bg-card rounded-lg shadow border border-border">
135+
<div className="p-4 border-b border-border">
136+
<div className="flex items-center justify-between mb-3">
137+
<h2 className="text-lg font-semibold text-foreground">
138+
Blockers{' '}
139+
<span className="text-sm font-normal text-muted-foreground">
140+
(0)
141+
</span>
142+
</h2>
143+
</div>
144+
145+
<FilterButtons filter={filter} setFilter={setFilter} />
146+
</div>
147+
148+
<div className="text-center py-8 text-muted-foreground">
149+
<p className="text-sm">No {filterDisplayName} blockers found</p>
150+
<p className="text-xs mt-1">Try selecting a different filter</p>
151+
</div>
152+
</div>
153+
);
154+
}
155+
82156
return (
83157
<div className="bg-card rounded-lg shadow border border-border">
84158
<div className="p-4 border-b border-border">
@@ -91,39 +165,7 @@ export default function BlockerPanel({ blockers, onBlockerClick }: BlockerPanelP
91165
</h2>
92166
</div>
93167

94-
{/* Filter buttons (T068) */}
95-
<div className="flex gap-2">
96-
<button
97-
onClick={() => setFilter('all')}
98-
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
99-
filter === 'all'
100-
? 'bg-primary text-primary-foreground'
101-
: 'bg-muted text-foreground hover:bg-muted/80'
102-
}`}
103-
>
104-
All
105-
</button>
106-
<button
107-
onClick={() => setFilter('sync')}
108-
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
109-
filter === 'sync'
110-
? 'bg-destructive text-destructive-foreground'
111-
: 'bg-muted text-foreground hover:bg-muted/80'
112-
}`}
113-
>
114-
SYNC
115-
</button>
116-
<button
117-
onClick={() => setFilter('async')}
118-
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
119-
filter === 'async'
120-
? 'bg-accent text-accent-foreground'
121-
: 'bg-muted text-foreground hover:bg-muted/80'
122-
}`}
123-
>
124-
ASYNC
125-
</button>
126-
</div>
168+
<FilterButtons filter={filter} setFilter={setFilter} />
127169
</div>
128170

129171
<div className="divide-y divide-border">

0 commit comments

Comments
 (0)