Skip to content

Commit 5096685

Browse files
committed
refactor: extract empty screen
1 parent db0d464 commit 5096685

6 files changed

Lines changed: 184 additions & 76 deletions

File tree

package/src/components/ChannelDetailsScreen/__tests__/ChannelAddMembers.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ describe('ChannelAddMembers', () => {
8585
renderComponent({ channel, queryUsers });
8686

8787
expect(screen.getByTestId('search-input')).toBeTruthy();
88-
expect(screen.getByTestId('channel-add-members-loading')).toBeTruthy();
88+
expect(screen.getByTestId('empty-search-result-loading')).toBeTruthy();
8989
});
9090

9191
it('fires an initial queryUsers call on mount with the role filter and pagination opts', async () => {
@@ -220,7 +220,7 @@ describe('ChannelAddMembers', () => {
220220

221221
renderComponent({ channel, queryUsers });
222222

223-
await waitFor(() => expect(screen.getByTestId('channel-add-members-empty')).toBeTruthy());
223+
await waitFor(() => expect(screen.getByTestId('empty-search-result')).toBeTruthy());
224224
expect(screen.getByText('No user found')).toBeTruthy();
225225
});
226226

package/src/components/ChannelDetailsScreen/components/ChannelAddMembers.tsx

Lines changed: 4 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
22
import {
3-
ActivityIndicator,
43
ColorValue,
54
FlatList,
65
I18nManager,
@@ -21,6 +20,7 @@ import { useStableCallback } from '../../../hooks/useStableCallback';
2120
import { Search } from '../../../icons/search';
2221
import { primitives } from '../../../theme';
2322
import { UserAvatar } from '../../ui/Avatar/UserAvatar';
23+
import { EmptySearchResult } from '../../UIComponents/EmptySearchResult';
2424
import { SearchInput } from '../../UIComponents/SearchInput';
2525
import { SelectionCircle } from '../../UIComponents/SelectionCircle';
2626
import { type AddMemberSearchResult, useChannelAddMembers } from '../hooks/useChannelAddMembers';
@@ -113,61 +113,13 @@ const ChannelAddMembersRow = React.memo(
113113

114114
ChannelAddMembersRow.displayName = 'ChannelAddMembersRow';
115115

116-
type EmptyStateProps = {
117-
containerStyle?: StyleProp<ViewStyle>;
118-
iconColor: ColorValue;
119-
label: string;
120-
loading: boolean;
121-
textColor: ColorValue;
122-
textStyle?: StyleProp<ViewStyle>;
123-
};
124-
125-
const ChannelAddMembersEmptyState = ({
126-
containerStyle,
127-
iconColor,
128-
label,
129-
loading,
130-
textColor,
131-
textStyle,
132-
}: EmptyStateProps) => {
133-
const styles = useStyles();
134-
135-
if (loading) {
136-
return (
137-
<View style={[styles.emptyState, containerStyle]} testID='channel-add-members-loading'>
138-
<ActivityIndicator color={iconColor} size='small' />
139-
</View>
140-
);
141-
}
142-
return (
143-
<View style={[styles.emptyState, containerStyle]} testID='channel-add-members-empty'>
144-
<Search height={24} stroke={iconColor} width={24} />
145-
<Text
146-
style={[
147-
styles.emptyStateText,
148-
{ color: textColor },
149-
{ fontSize: primitives.typographyFontSizeMd },
150-
textStyle,
151-
]}
152-
>
153-
{label}
154-
</Text>
155-
</View>
156-
);
157-
};
158-
159116
export const ChannelAddMembers = ({ onSelectionChange }: ChannelAddMembersProps) => {
160117
const { channel } = useChannelDetailsContext();
161118
const { t } = useTranslationContext();
162119
const {
163120
theme: {
164121
channelDetailsScreen: {
165-
addMembers: {
166-
emptyState: emptyStateOverride,
167-
emptyStateText: emptyStateTextOverride,
168-
userName: userNameOverride,
169-
userRow: userRowOverride,
170-
},
122+
addMembers: { userName: userNameOverride, userRow: userRowOverride },
171123
},
172124
semantics,
173125
},
@@ -228,13 +180,10 @@ export const ChannelAddMembers = ({ onSelectionChange }: ChannelAddMembersProps)
228180
);
229181

230182
const emptyStateElement = (
231-
<ChannelAddMembersEmptyState
232-
containerStyle={emptyStateOverride}
233-
iconColor={semantics.textTertiary}
183+
<EmptySearchResult
184+
icon={<Search height={24} stroke={semantics.textTertiary} width={24} />}
234185
label={t('No user found')}
235186
loading={loading}
236-
textColor={semantics.textSecondary}
237-
textStyle={emptyStateTextOverride}
238187
/>
239188
);
240189

@@ -270,21 +219,6 @@ const useStyles = () => {
270219
container: {
271220
flex: 1,
272221
},
273-
emptyState: {
274-
alignItems: 'center',
275-
gap: primitives.spacingSm,
276-
justifyContent: 'center',
277-
paddingVertical: primitives.spacingXl,
278-
height: '100%',
279-
width: '100%',
280-
},
281-
emptyStateText: {
282-
fontSize: primitives.typographyFontSizeMd,
283-
fontWeight: primitives.typographyFontWeightRegular,
284-
lineHeight: primitives.typographyLineHeightNormal,
285-
textAlign: 'center',
286-
writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr',
287-
},
288222
list: {
289223
flex: 1,
290224
},
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from 'react';
2+
import { I18nManager, StyleSheet, Text, View } from 'react-native';
3+
4+
import { useTheme } from '../../contexts/themeContext/ThemeContext';
5+
import { primitives } from '../../theme';
6+
import { Spinner } from './Spinner';
7+
8+
export type EmptySearchResultProps = {
9+
icon: React.ReactNode;
10+
label: string;
11+
loading?: boolean;
12+
};
13+
14+
export const EmptySearchResult = React.memo(
15+
({ icon, label, loading = false }: EmptySearchResultProps) => {
16+
const {
17+
theme: {
18+
emptySearchResult: { container, text },
19+
semantics,
20+
},
21+
} = useTheme();
22+
23+
if (loading) {
24+
return (
25+
<View style={[styles.container, container]} testID='empty-search-result-loading'>
26+
<Spinner height={24} width={24} />
27+
</View>
28+
);
29+
}
30+
31+
return (
32+
<View style={[styles.container, container]} testID='empty-search-result'>
33+
{icon}
34+
<Text style={[styles.text, { color: semantics.textSecondary }, text]}>{label}</Text>
35+
</View>
36+
);
37+
},
38+
);
39+
40+
EmptySearchResult.displayName = 'EmptySearchResult{emptySearchResult}';
41+
42+
const styles = StyleSheet.create({
43+
container: {
44+
alignItems: 'center',
45+
gap: primitives.spacingSm,
46+
height: '100%',
47+
justifyContent: 'center',
48+
paddingVertical: primitives.spacingXl,
49+
width: '100%',
50+
},
51+
text: {
52+
fontSize: primitives.typographyFontSizeMd,
53+
fontWeight: primitives.typographyFontWeightRegular,
54+
lineHeight: primitives.typographyLineHeightNormal,
55+
textAlign: 'center',
56+
writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr',
57+
},
58+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import React from 'react';
2+
import { Text, View } from 'react-native';
3+
4+
import { render, screen } from '@testing-library/react-native';
5+
6+
import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext';
7+
import { defaultTheme } from '../../../contexts/themeContext/utils/theme';
8+
import { EmptySearchResult } from '../EmptySearchResult';
9+
10+
type EmptySearchResultProps = React.ComponentProps<typeof EmptySearchResult>;
11+
12+
const renderComponent = (props: Partial<EmptySearchResultProps> = {}) =>
13+
render(
14+
<ThemeProvider theme={defaultTheme}>
15+
<EmptySearchResult
16+
icon={<View testID='empty-search-result-icon' />}
17+
label='No results'
18+
{...props}
19+
/>
20+
</ThemeProvider>,
21+
);
22+
23+
describe('EmptySearchResult', () => {
24+
it('renders the empty-search-result testID', () => {
25+
renderComponent();
26+
27+
expect(screen.getByTestId('empty-search-result')).toBeTruthy();
28+
});
29+
30+
it('renders the icon node passed via the icon prop', () => {
31+
renderComponent({ icon: <View testID='custom-icon' /> });
32+
33+
expect(screen.getByTestId('custom-icon')).toBeTruthy();
34+
});
35+
36+
it('renders the label text', () => {
37+
renderComponent({ label: 'Nothing here' });
38+
39+
expect(screen.getByText('Nothing here')).toBeTruthy();
40+
});
41+
42+
it('honors a custom container style override from the theme', () => {
43+
const customTheme = {
44+
...defaultTheme,
45+
emptySearchResult: {
46+
container: { backgroundColor: 'rgb(255, 0, 0)' },
47+
text: {},
48+
},
49+
};
50+
51+
render(
52+
<ThemeProvider theme={customTheme}>
53+
<EmptySearchResult icon={<View />} label='Empty' />
54+
</ThemeProvider>,
55+
);
56+
57+
const container = screen.getByTestId('empty-search-result');
58+
const flattened = Array.isArray(container.props.style)
59+
? Object.assign({}, ...container.props.style.flat(Infinity).filter(Boolean))
60+
: container.props.style;
61+
expect(flattened.backgroundColor).toBe('rgb(255, 0, 0)');
62+
});
63+
64+
it('honors a custom text style override from the theme', () => {
65+
const customTheme = {
66+
...defaultTheme,
67+
emptySearchResult: {
68+
container: {},
69+
text: { color: 'rgb(0, 255, 0)' },
70+
},
71+
};
72+
73+
render(
74+
<ThemeProvider theme={customTheme}>
75+
<EmptySearchResult icon={<View />} label='Empty' />
76+
</ThemeProvider>,
77+
);
78+
79+
const label = screen.getByText('Empty') as unknown as { props: { style: unknown } };
80+
const flattened = Array.isArray(label.props.style)
81+
? Object.assign({}, ...(label.props.style as unknown[]).flat(Infinity).filter(Boolean))
82+
: label.props.style;
83+
expect((flattened as { color?: string }).color).toBe('rgb(0, 255, 0)');
84+
});
85+
86+
it('renders any node passed as icon, including text', () => {
87+
renderComponent({ icon: <Text testID='icon-as-text'>icon</Text> });
88+
89+
expect(screen.getByTestId('icon-as-text')).toBeTruthy();
90+
});
91+
92+
it('renders the loading testID and hides the icon/label when loading is true', () => {
93+
renderComponent({
94+
icon: <View testID='hidden-icon' />,
95+
label: 'No results',
96+
loading: true,
97+
});
98+
99+
expect(screen.getByTestId('empty-search-result-loading')).toBeTruthy();
100+
expect(screen.queryByTestId('empty-search-result')).toBeNull();
101+
expect(screen.queryByTestId('hidden-icon')).toBeNull();
102+
expect(screen.queryByText('No results')).toBeNull();
103+
});
104+
105+
it('renders the icon/label when loading is false', () => {
106+
renderComponent({ loading: false });
107+
108+
expect(screen.getByTestId('empty-search-result')).toBeTruthy();
109+
expect(screen.queryByTestId('empty-search-result-loading')).toBeNull();
110+
});
111+
});

package/src/components/UIComponents/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './BottomSheetModal';
22
export * from './StreamBottomSheetModalFlatList';
3+
export * from './EmptySearchResult';
34
export * from './ImageBackground';
45
export * from './SearchInput';
56
export * from './SelectionCircle';

package/src/contexts/themeContext/utils/theme.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,6 @@ export type Theme = {
221221
status: TextStyle;
222222
};
223223
addMembers: {
224-
emptyState: ViewStyle;
225-
emptyStateText: TextStyle;
226224
userName: TextStyle;
227225
userRow: ViewStyle;
228226
};
@@ -292,6 +290,10 @@ export type Theme = {
292290
container: ViewStyle;
293291
text: TextStyle;
294292
};
293+
emptySearchResult: {
294+
container: ViewStyle;
295+
text: TextStyle;
296+
};
295297
emptyStateIndicator: {
296298
channelContainer: ViewStyle;
297299
channelDetails: TextStyle;
@@ -1226,8 +1228,6 @@ export const defaultTheme: Theme = {
12261228
status: {},
12271229
},
12281230
addMembers: {
1229-
emptyState: {},
1230-
emptyStateText: {},
12311231
userName: {},
12321232
userRow: {},
12331233
},
@@ -1297,6 +1297,10 @@ export const defaultTheme: Theme = {
12971297
container: {},
12981298
text: {},
12991299
},
1300+
emptySearchResult: {
1301+
container: {},
1302+
text: {},
1303+
},
13001304
emptyStateIndicator: {
13011305
channelContainer: {},
13021306
channelDetails: {},

0 commit comments

Comments
 (0)