Skip to content

Commit 0f8016c

Browse files
committed
refactor: upon source activation perform search request in click handler
1 parent 6684eaf commit 0f8016c

File tree

2 files changed

+158
-118
lines changed

2 files changed

+158
-118
lines changed

src/experimental/Search/SearchResults/SearchResultsHeader.tsx

Lines changed: 49 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,56 +5,64 @@ import { useSearchContext } from '../SearchContext';
55
import { useTranslationContext } from '../../../context';
66
import { useStateStore } from '../../../store';
77

8-
import type { SearchControllerState } from 'stream-chat';
8+
import type { SearchSource, SearchSourceState } from 'stream-chat';
99
import type { DefaultStreamChatGenerics } from '../../../types';
1010

11-
const searchControllerStateSelector = <
12-
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
13-
>(
14-
nextValue: SearchControllerState<StreamChatGenerics>,
15-
) => ({
16-
activeSourceTypes: nextValue.sources.reduce<string[]>((acc, s) => {
17-
if (s.isActive) {
18-
acc.push(s.type);
19-
}
20-
return acc;
21-
}, []),
11+
const searchSourceStateSelector = (nextValue: SearchSourceState) => ({
12+
isActive: nextValue.isActive,
2213
});
2314

24-
export const SearchResultsHeader = <
15+
type SearchSourceFilterButtonProps = {
16+
source: SearchSource;
17+
};
18+
19+
const SearchSourceFilterButton = <
2520
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
26-
>() => {
21+
>({
22+
source,
23+
}: SearchSourceFilterButtonProps) => {
2724
const { t } = useTranslationContext();
2825
const { searchController } = useSearchContext<StreamChatGenerics>();
29-
const { activeSourceTypes } = useStateStore(
30-
searchController.state,
31-
searchControllerStateSelector,
26+
const { isActive } = useStateStore(source.state, searchSourceStateSelector);
27+
const label = `search-results-header-filter-source-button-label--${source.type}`;
28+
return (
29+
<button
30+
aria-label={t('aria/Search results header filter button')}
31+
className={clsx('str-chat__search-results-header__filter-source-button', {
32+
'str-chat__search-results-header__filter-source-button--active': isActive,
33+
})}
34+
key={label}
35+
onClick={() => {
36+
if (source.isActive) {
37+
searchController.deactivateSource(source.type);
38+
} else {
39+
searchController.activateSource(source.type);
40+
if (searchController.searchQuery && !source.items?.length)
41+
source.search(searchController.searchQuery);
42+
}
43+
}}
44+
>
45+
{t<string>(label)}
46+
</button>
3247
);
48+
};
49+
50+
export const SearchResultsHeader = <
51+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
52+
>() => {
53+
const { searchController } = useSearchContext<StreamChatGenerics>();
3354
return (
34-
<div className='str-chat__search-results-header'>
35-
<div className='str-chat__search-results-header__filter-source-buttons'>
36-
{searchController.searchSourceTypes.map((searchSourceType) => {
37-
const label = `search-results-header-filter-source-button-label--${searchSourceType}`;
38-
const isActive = activeSourceTypes.includes(searchSourceType);
39-
return (
40-
<button
41-
aria-label={t('aria/Search results header filter button')}
42-
className={clsx('str-chat__search-results-header__filter-source-button', {
43-
'str-chat__search-results-header__filter-source-button--active': isActive,
44-
})}
45-
key={label}
46-
onClick={() => {
47-
if (activeSourceTypes.includes(searchSourceType)) {
48-
searchController.deactivateSource(searchSourceType);
49-
} else {
50-
searchController.activateSource(searchSourceType);
51-
}
52-
}}
53-
>
54-
{t<string>(label)}
55-
</button>
56-
);
57-
})}
55+
<div className='str-chat__search-results-header' data-testid='search-results-header'>
56+
<div
57+
className='str-chat__search-results-header__filter-source-buttons'
58+
data-testid='filter-source-buttons'
59+
>
60+
{searchController.sources.map((source) => (
61+
<SearchSourceFilterButton
62+
key={`search-source-filter-button-${source.type}`}
63+
source={source}
64+
/>
65+
))}
5866
</div>
5967
</div>
6068
);
Lines changed: 109 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { fireEvent, render, screen } from '@testing-library/react';
2+
import '@testing-library/jest-dom';
23
import React from 'react';
34

45
import { SearchResultsHeader } from '../SearchResults';
@@ -11,16 +12,33 @@ jest.mock('../../../context');
1112
jest.mock('../../../store');
1213

1314
describe('SearchResultsHeader', () => {
15+
const mockSources = {
16+
channels: { items: [], search: jest.fn(), state: {}, type: 'channels' },
17+
messages: { items: ['message1'], search: jest.fn(), state: {}, type: 'messages' },
18+
users: { items: [], search: jest.fn(), state: {}, type: 'users' },
19+
};
20+
1421
const mockSearchController = {
1522
activateSource: jest.fn(),
1623
deactivateSource: jest.fn(),
17-
searchSourceTypes: ['users', 'channels', 'messages'],
18-
state: {},
24+
searchQuery: 'test query',
25+
get sources() {
26+
return Object.entries(mockSources).map(([type, source]) => ({
27+
type,
28+
...source,
29+
}));
30+
},
1931
};
2032

2133
beforeEach(() => {
2234
jest.clearAllMocks();
2335

36+
// Reset mock sources
37+
Object.values(mockSources).forEach((source) => {
38+
source.items = source.type === 'messages' ? ['message1'] : [];
39+
source.search.mockClear();
40+
});
41+
2442
useSearchContext.mockReturnValue({
2543
searchController: mockSearchController,
2644
});
@@ -29,100 +47,114 @@ describe('SearchResultsHeader', () => {
2947
t: (key) => key,
3048
});
3149

32-
useStateStore.mockReturnValue({
33-
activeSourceTypes: ['users', 'messages'],
34-
});
35-
});
36-
37-
it('renders filter source buttons for each source type', () => {
38-
render(<SearchResultsHeader />);
39-
expect(
40-
screen.getAllByRole('button', {
41-
name: 'aria/Search results header filter button',
42-
}),
43-
).toHaveLength(3);
50+
useStateStore.mockReturnValue({ isActive: false });
4451
});
4552

46-
it('applies active class to active source type buttons', () => {
47-
render(<SearchResultsHeader />);
48-
49-
const buttons = screen.getAllByRole('button');
50-
51-
buttons.forEach((button) => {
52-
const buttonClasses = button.className;
53-
if (
54-
button.textContent === 'search-results-header-filter-source-button-label--users' ||
55-
button.textContent === 'search-results-header-filter-source-button-label--messages'
56-
) {
57-
expect(buttonClasses).toContain(
58-
'str-chat__search-results-header__filter-source-button--active',
59-
);
60-
} else {
61-
expect(buttonClasses).not.toContain(
62-
'str-chat__search-results-header__filter-source-button--active',
63-
);
64-
}
53+
describe('rendering', () => {
54+
it('renders container with correct classes and structure', () => {
55+
render(<SearchResultsHeader />);
56+
expect(screen.getByTestId('search-results-header')).toHaveClass(
57+
'str-chat__search-results-header',
58+
);
59+
expect(screen.getByTestId('filter-source-buttons')).toHaveClass(
60+
'str-chat__search-results-header__filter-source-buttons',
61+
);
6562
});
66-
});
6763

68-
it('deactivates source when clicking active source button', () => {
69-
render(<SearchResultsHeader />);
70-
71-
const usersButton = screen.getByText('search-results-header-filter-source-button-label--users');
72-
fireEvent.click(usersButton);
64+
it('renders a button for each source type', () => {
65+
render(<SearchResultsHeader />);
66+
const buttons = screen.getAllByRole('button');
67+
expect(buttons).toHaveLength(3);
68+
69+
expect(
70+
screen.getByText('search-results-header-filter-source-button-label--channels'),
71+
).toBeInTheDocument();
72+
expect(
73+
screen.getByText('search-results-header-filter-source-button-label--messages'),
74+
).toBeInTheDocument();
75+
expect(
76+
screen.getByText('search-results-header-filter-source-button-label--users'),
77+
).toBeInTheDocument();
78+
});
7379

74-
expect(mockSearchController.deactivateSource).toHaveBeenCalledWith('users');
75-
expect(mockSearchController.activateSource).not.toHaveBeenCalled();
80+
it('applies correct aria-labels to all buttons', () => {
81+
render(<SearchResultsHeader />);
82+
const buttons = screen.getAllByRole('button');
83+
buttons.forEach((button) => {
84+
expect(button).toHaveAttribute('aria-label', 'aria/Search results header filter button');
85+
});
86+
});
7687
});
7788

78-
it('activates source when clicking inactive source button', () => {
79-
render(<SearchResultsHeader />);
89+
describe('button states and styling', () => {
90+
it('applies active class to button when source is active', () => {
91+
useStateStore.mockReturnValue({ isActive: true });
92+
render(<SearchResultsHeader />);
8093

81-
const channelsButton = screen.getByText(
82-
'search-results-header-filter-source-button-label--channels',
83-
);
84-
fireEvent.click(channelsButton);
94+
const button = screen.getByText('search-results-header-filter-source-button-label--messages');
95+
expect(button).toHaveClass('str-chat__search-results-header__filter-source-button--active');
96+
});
97+
98+
it('does not apply active class when source is inactive', () => {
99+
useStateStore.mockReturnValue({ isActive: false });
100+
render(<SearchResultsHeader />);
85101

86-
expect(mockSearchController.activateSource).toHaveBeenCalledWith('channels');
87-
expect(mockSearchController.deactivateSource).not.toHaveBeenCalled();
102+
const button = screen.getByText('search-results-header-filter-source-button-label--messages');
103+
expect(button).not.toHaveClass(
104+
'str-chat__search-results-header__filter-source-button--active',
105+
);
106+
});
88107
});
89108

90-
it('translates button labels correctly', () => {
91-
const mockTranslate = jest.fn((key) => `Translated ${key}`);
92-
useTranslationContext.mockReturnValue({ t: mockTranslate });
109+
describe('button interactions', () => {
110+
it('deactivates source when clicking active source button', () => {
111+
Object.values(mockSources).forEach((source) => {
112+
if (source.type !== 'messages') return;
113+
source.isActive = true;
114+
});
115+
render(<SearchResultsHeader />);
116+
117+
fireEvent.click(
118+
screen.getByText('search-results-header-filter-source-button-label--messages'),
119+
);
120+
expect(mockSearchController.deactivateSource).toHaveBeenCalledWith('messages');
121+
expect(mockSearchController.activateSource).not.toHaveBeenCalled();
93122

94-
render(<SearchResultsHeader />);
123+
Object.values(mockSources).forEach((source) => {
124+
if (source.type !== 'messages') return;
125+
source.isActive = undefined;
126+
});
127+
});
95128

96-
expect(mockTranslate).toHaveBeenCalledWith('aria/Search results header filter button');
97-
mockSearchController.searchSourceTypes.forEach((sourceType) => {
98-
expect(mockTranslate).toHaveBeenCalledWith(
99-
`search-results-header-filter-source-button-label--${sourceType}`,
129+
it('activates and searches source with no items', () => {
130+
render(<SearchResultsHeader />);
131+
fireEvent.click(
132+
screen.getByText('search-results-header-filter-source-button-label--channels'),
100133
);
134+
135+
expect(mockSearchController.activateSource).toHaveBeenCalledWith('channels');
136+
expect(mockSources.channels.search).toHaveBeenCalledWith('test query');
101137
});
102-
});
103138

104-
it('handles state updates correctly', () => {
105-
const { rerender } = render(<SearchResultsHeader />);
139+
it('only performs search upon activation if it does not have items loaded', () => {
140+
render(<SearchResultsHeader />);
141+
fireEvent.click(
142+
screen.getByText('search-results-header-filter-source-button-label--messages'),
143+
);
106144

107-
// Update active sources
108-
useStateStore.mockReturnValue({
109-
activeSourceTypes: ['channels'],
145+
expect(mockSearchController.activateSource).toHaveBeenCalledWith('messages');
146+
expect(mockSources.messages.search).not.toHaveBeenCalled();
110147
});
111148

112-
rerender(<SearchResultsHeader />);
113-
114-
const buttons = screen.getAllByRole('button');
115-
buttons.forEach((button) => {
116-
const buttonClasses = button.className;
117-
if (button.textContent === 'search-results-header-filter-source-button-label--channels') {
118-
expect(buttonClasses).toContain(
119-
'str-chat__search-results-header__filter-source-button--active',
120-
);
121-
} else {
122-
expect(buttonClasses).not.toContain(
123-
'str-chat__search-results-header__filter-source-button--active',
124-
);
125-
}
149+
it('does not perform search upon activation if it search query is empty', () => {
150+
mockSearchController.searchQuery = '';
151+
render(<SearchResultsHeader />);
152+
153+
fireEvent.click(
154+
screen.getByText('search-results-header-filter-source-button-label--channels'),
155+
);
156+
expect(mockSearchController.activateSource).toHaveBeenCalledWith('channels');
157+
expect(mockSources.channels.search).not.toHaveBeenCalled();
126158
});
127159
});
128160
});

0 commit comments

Comments
 (0)