@linktr.ee/messaging-react 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.js +149 -140
  2. package/dist/index.js.map +1 -1
  3. package/package.json +5 -4
  4. package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
  5. package/src/components/ActionButton/ActionButton.test.tsx +112 -0
  6. package/src/components/ActionButton/index.tsx +33 -0
  7. package/src/components/Avatar/Avatar.stories.tsx +144 -0
  8. package/src/components/Avatar/avatarColors.ts +35 -0
  9. package/src/components/Avatar/index.tsx +64 -0
  10. package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
  11. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
  12. package/src/components/ChannelList/CustomChannelPreview.tsx +121 -0
  13. package/src/components/ChannelList/index.tsx +129 -0
  14. package/src/components/ChannelView.tsx +422 -0
  15. package/src/components/CloseButton/index.tsx +16 -0
  16. package/src/components/IconButton/IconButton.stories.tsx +40 -0
  17. package/src/components/IconButton/index.tsx +32 -0
  18. package/src/components/Loading/Loading.stories.tsx +24 -0
  19. package/src/components/Loading/index.tsx +50 -0
  20. package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
  21. package/src/components/MessagingShell/EmptyState.tsx +58 -0
  22. package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
  23. package/src/components/MessagingShell/ErrorState.tsx +33 -0
  24. package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
  25. package/src/components/MessagingShell/LoadingState.tsx +15 -0
  26. package/src/components/MessagingShell/index.tsx +298 -0
  27. package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
  28. package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
  29. package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
  30. package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
  31. package/src/components/ParticipantPicker/index.tsx +234 -0
  32. package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
  33. package/src/components/SearchInput/SearchInput.test.tsx +108 -0
  34. package/src/components/SearchInput/index.tsx +50 -0
  35. package/src/hooks/useMessaging.ts +9 -0
  36. package/src/hooks/useParticipants.ts +92 -0
  37. package/src/index.ts +26 -0
  38. package/src/providers/MessagingProvider.tsx +282 -0
  39. package/src/stories/mocks.tsx +157 -0
  40. package/src/test/setup.ts +30 -0
  41. package/src/test/utils.tsx +23 -0
  42. package/src/types.ts +113 -0
@@ -0,0 +1,92 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import type { ParticipantSource, Participant } from '../types';
3
+
4
+ /**
5
+ * Hook for managing participant loading with search and pagination
6
+ */
7
+ export const useParticipants = (
8
+ participantSource: ParticipantSource,
9
+ options: {
10
+ initialSearch?: string;
11
+ pageSize?: number;
12
+ } = {}
13
+ ) => {
14
+ const { initialSearch = '', pageSize = 20 } = options;
15
+
16
+ const [participants, setParticipants] = useState<Participant[]>([]);
17
+ const [loading, setLoading] = useState(false);
18
+ const [error, setError] = useState<string | null>(null);
19
+ const [searchQuery, setSearchQuery] = useState(initialSearch);
20
+ const [hasMore, setHasMore] = useState(true);
21
+ const [cursor, setCursor] = useState<string | undefined>();
22
+
23
+ // Load participants with current search query
24
+ const loadParticipants = useCallback(async (
25
+ reset = false,
26
+ customSearch?: string
27
+ ) => {
28
+ if (loading) return;
29
+
30
+ const search = customSearch !== undefined ? customSearch : searchQuery;
31
+
32
+ setLoading(true);
33
+ setError(null);
34
+
35
+ try {
36
+ const result = await participantSource.loadParticipants({
37
+ search: search || undefined,
38
+ limit: pageSize,
39
+ cursor: reset ? undefined : cursor,
40
+ });
41
+
42
+ setParticipants(prev =>
43
+ reset ? result.participants : [...prev, ...result.participants]
44
+ );
45
+ setHasMore(result.hasMore);
46
+ setCursor(result.nextCursor);
47
+ } catch (err) {
48
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load participants';
49
+ setError(errorMessage);
50
+ console.error('[useParticipants] Load error:', err);
51
+ } finally {
52
+ setLoading(false);
53
+ }
54
+ }, [participantSource, searchQuery, cursor, pageSize, loading]);
55
+
56
+ // Load more participants (pagination)
57
+ const loadMore = useCallback(() => {
58
+ if (hasMore && !loading) {
59
+ loadParticipants(false);
60
+ }
61
+ }, [hasMore, loading, loadParticipants]);
62
+
63
+ // Search participants
64
+ const search = useCallback((query: string) => {
65
+ setSearchQuery(query);
66
+ setCursor(undefined);
67
+ loadParticipants(true, query);
68
+ }, [loadParticipants]);
69
+
70
+ // Refresh participants
71
+ const refresh = useCallback(() => {
72
+ setCursor(undefined);
73
+ loadParticipants(true);
74
+ }, [loadParticipants]);
75
+
76
+ // Initial load - only run once when participantSource changes
77
+ useEffect(() => {
78
+ loadParticipants(true);
79
+ }, [participantSource.loadParticipants]); // Only depend on the function to avoid loops
80
+
81
+ return {
82
+ participants,
83
+ loading,
84
+ error,
85
+ searchQuery,
86
+ hasMore,
87
+ totalCount: participantSource.totalCount,
88
+ loadMore,
89
+ search,
90
+ refresh,
91
+ };
92
+ };
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Components
2
+ export { MessagingShell } from './components/MessagingShell';
3
+ export { ChannelList } from './components/ChannelList';
4
+ export { ChannelView } from './components/ChannelView';
5
+ export { ParticipantPicker } from './components/ParticipantPicker';
6
+ export { Avatar } from './components/Avatar';
7
+
8
+ // Providers
9
+ export { MessagingProvider } from './providers/MessagingProvider';
10
+
11
+ // Hooks
12
+ export { useMessaging } from './hooks/useMessaging';
13
+ export { useParticipants } from './hooks/useParticipants';
14
+
15
+ // Types
16
+ export type {
17
+ MessagingShellProps,
18
+ ChannelListProps,
19
+ ChannelViewProps,
20
+ ParticipantPickerProps,
21
+ MessagingProviderProps,
22
+ MessagingCapabilities,
23
+ ParticipantSource,
24
+ Participant
25
+ } from './types';
26
+ export type { AvatarProps } from './components/Avatar';
@@ -0,0 +1,282 @@
1
+ import React, { createContext, useContext, useEffect, useState, useRef, useCallback } from 'react';
2
+ import type { Channel } from 'stream-chat';
3
+ import { Chat } from 'stream-chat-react';
4
+ import { StreamChatService } from '@linktr.ee/messaging-core';
5
+
6
+ import type {
7
+ MessagingProviderProps,
8
+ MessagingCapabilities,
9
+ MessagingCustomization
10
+ } from '../types';
11
+
12
+ /**
13
+ * Context value for messaging state and service
14
+ */
15
+ export interface MessagingContextValue {
16
+ service: StreamChatService | null;
17
+ client: any; // Stream Chat client
18
+ isConnected: boolean;
19
+ isLoading: boolean;
20
+ error: string | null;
21
+ capabilities: MessagingCapabilities;
22
+ customization: MessagingCustomization;
23
+ refreshConnection: () => Promise<void>;
24
+ debug: boolean;
25
+ }
26
+
27
+ const MessagingContext = createContext<MessagingContextValue>({
28
+ service: null,
29
+ client: null,
30
+ isConnected: false,
31
+ isLoading: false,
32
+ error: null,
33
+ capabilities: {},
34
+ customization: {},
35
+ refreshConnection: async () => {},
36
+ debug: false,
37
+ });
38
+
39
+ /**
40
+ * Hook to access messaging context
41
+ */
42
+ export const useMessagingContext = () => useContext(MessagingContext);
43
+
44
+ /**
45
+ * Provider component that wraps messaging-core with React state management
46
+ */
47
+ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
48
+ children,
49
+ user,
50
+ serviceConfig,
51
+ apiKey,
52
+ capabilities = {},
53
+ customization = {},
54
+ debug = false,
55
+ }) => {
56
+ // Create debug logger that respects the debug prop
57
+ const debugLog = (message: string, ...args: any[]) => {
58
+ if (debug) {
59
+ console.log(`🔥 [MessagingProvider] ${message}`, ...args);
60
+ }
61
+ };
62
+
63
+ debugLog('🔄 RENDER START', {
64
+ userId: user?.id,
65
+ apiKey: apiKey?.substring(0, 8) + '...',
66
+ serviceConfig: !!serviceConfig,
67
+ capabilities: Object.keys(capabilities),
68
+ customization: Object.keys(customization)
69
+ });
70
+
71
+ const [service, setService] = useState<StreamChatService | null>(null);
72
+ const [client, setClient] = useState<any>(null);
73
+ const [isConnected, setIsConnected] = useState(false);
74
+ const [isLoading, setIsLoading] = useState(false);
75
+ const [error, setError] = useState<string | null>(null);
76
+
77
+ // Prevent multiple concurrent connection attempts
78
+ const connectingRef = useRef(false);
79
+
80
+ // Track renders and prop changes
81
+ const prevPropsRef = useRef({ userId: user?.id, apiKey, serviceConfig, capabilities, customization });
82
+ const renderCountRef = useRef(0);
83
+ renderCountRef.current++;
84
+
85
+ debugLog('📊 RENDER INFO', {
86
+ renderCount: renderCountRef.current,
87
+ currentProps: { userId: user?.id, apiKey: apiKey?.substring(0, 8) + '...' },
88
+ propChanges: {
89
+ userChanged: prevPropsRef.current.userId !== user?.id,
90
+ apiKeyChanged: prevPropsRef.current.apiKey !== apiKey,
91
+ serviceConfigChanged: prevPropsRef.current.serviceConfig !== serviceConfig,
92
+ capabilitiesChanged: prevPropsRef.current.capabilities !== capabilities,
93
+ customizationChanged: prevPropsRef.current.customization !== customization
94
+ }
95
+ });
96
+
97
+ prevPropsRef.current = { userId: user?.id, apiKey, serviceConfig, capabilities, customization };
98
+
99
+ // Initialize service when config changes
100
+ useEffect(() => {
101
+ const currentRender = renderCountRef.current;
102
+ debugLog('🔧 SERVICE INIT EFFECT TRIGGERED', {
103
+ renderCount: currentRender,
104
+ apiKey: !!apiKey,
105
+ serviceConfig: !!serviceConfig,
106
+ dependencies: {
107
+ apiKey: apiKey?.substring(0, 8) + '...',
108
+ serviceConfigRef: serviceConfig,
109
+ serviceConfigStable: prevPropsRef.current.serviceConfig === serviceConfig,
110
+ apiKeyStable: prevPropsRef.current.apiKey === apiKey
111
+ }
112
+ });
113
+
114
+ if (!apiKey || !serviceConfig) {
115
+ debugLog('⚠️ SERVICE INIT SKIPPED', { renderCount: currentRender, reason: 'Missing apiKey or serviceConfig' });
116
+ return;
117
+ }
118
+
119
+ debugLog('🚀 CREATING NEW SERVICE', {
120
+ renderCount: currentRender,
121
+ apiKey: apiKey?.substring(0, 8) + '...',
122
+ serviceConfigChanged: prevPropsRef.current.serviceConfig !== serviceConfig
123
+ });
124
+
125
+ const newService = new StreamChatService({
126
+ ...serviceConfig,
127
+ apiKey,
128
+ debug,
129
+ });
130
+
131
+ setService(newService);
132
+ debugLog('✅ SERVICE SET', { renderCount: currentRender, serviceInstance: !!newService });
133
+
134
+ return () => {
135
+ debugLog('🧹 SERVICE CLEANUP', { renderCount: currentRender, reason: 'Effect cleanup' });
136
+ newService.disconnectUser().catch(console.error);
137
+ };
138
+ }, [apiKey, serviceConfig]); // Use serviceConfig object directly, not individual properties
139
+
140
+ // Track if we've already connected this user with this service to prevent duplicate connections
141
+ const connectedUserRef = useRef<{serviceId: any, userId: string} | null>(null);
142
+
143
+ // Connect user when service and user are available
144
+ useEffect(() => {
145
+ debugLog('🔗 USER CONNECTION EFFECT TRIGGERED', {
146
+ hasService: !!service,
147
+ hasUser: !!user,
148
+ userId: user?.id,
149
+ isConnecting: connectingRef.current,
150
+ isConnected: isConnected,
151
+ dependencies: { service: !!service, userId: user?.id }
152
+ });
153
+
154
+ if (!service || !user) {
155
+ debugLog('⚠️ USER CONNECTION SKIPPED', 'Missing service or user');
156
+ return;
157
+ }
158
+
159
+ if (connectingRef.current) {
160
+ debugLog('⚠️ USER CONNECTION SKIPPED', 'Already connecting');
161
+ return;
162
+ }
163
+
164
+ // Check if we've already connected this exact user with this exact service instance
165
+ if (connectedUserRef.current?.serviceId === service && connectedUserRef.current?.userId === user.id) {
166
+ debugLog('⚠️ USER CONNECTION SKIPPED', 'Already connected this user with this service');
167
+ return;
168
+ }
169
+
170
+ const connectUser = async () => {
171
+ debugLog('🚀 STARTING USER CONNECTION', { userId: user.id });
172
+ connectingRef.current = true;
173
+ setIsLoading(true);
174
+ setError(null);
175
+
176
+ try {
177
+ debugLog('📞 CALLING SERVICE.CONNECTUSER', { userId: user.id });
178
+ const streamClient = await service.connectUser(user);
179
+ setClient(streamClient);
180
+ setIsConnected(true);
181
+ connectedUserRef.current = { serviceId: service, userId: user.id }; // Mark as connected
182
+ debugLog('✅ USER CONNECTION SUCCESS', { userId: user.id, clientId: streamClient.userID });
183
+ } catch (err) {
184
+ const errorMessage = err instanceof Error ? err.message : 'Connection failed';
185
+ setError(errorMessage);
186
+ debugLog('❌ USER CONNECTION ERROR', { userId: user.id, error: errorMessage });
187
+ } finally {
188
+ setIsLoading(false);
189
+ connectingRef.current = false;
190
+ debugLog('🔄 USER CONNECTION FINISHED', { userId: user.id, isConnected });
191
+ }
192
+ };
193
+
194
+ connectUser();
195
+ }, [service, user]); // Remove isConnected to prevent circular dependency
196
+
197
+ // Disconnect when user is removed (cleanup effect)
198
+ useEffect(() => {
199
+ debugLog('🔌 CLEANUP EFFECT REGISTERED', { hasService: !!service, isConnected });
200
+ return () => {
201
+ if (service && isConnected) {
202
+ debugLog('🧹 CLEANUP EFFECT TRIGGERED', 'Cleaning up connection on unmount');
203
+ connectedUserRef.current = null; // Reset connection tracking
204
+ service.disconnectUser().catch(console.error);
205
+ } else {
206
+ debugLog('🔇 CLEANUP EFFECT SKIPPED', { hasService: !!service, isConnected });
207
+ }
208
+ };
209
+ }, [service, isConnected]);
210
+
211
+ const refreshConnection = useCallback(async () => {
212
+ debugLog('🔄 REFRESH CONNECTION CALLED', { hasService: !!service, hasUser: !!user });
213
+
214
+ if (!service || !user) {
215
+ debugLog('⚠️ REFRESH CONNECTION SKIPPED', 'Missing service or user');
216
+ return;
217
+ }
218
+
219
+ debugLog('🚀 STARTING CONNECTION REFRESH', { userId: user.id });
220
+ setIsLoading(true);
221
+ try {
222
+ debugLog('🔌 DISCONNECTING FOR REFRESH');
223
+ await service.disconnectUser();
224
+ debugLog('📞 RECONNECTING FOR REFRESH');
225
+ const streamClient = await service.connectUser(user);
226
+ setClient(streamClient);
227
+ setIsConnected(true);
228
+ setError(null);
229
+ debugLog('✅ CONNECTION REFRESH SUCCESS', { userId: user.id });
230
+ } catch (err) {
231
+ const errorMessage = err instanceof Error ? err.message : 'Refresh failed';
232
+ setError(errorMessage);
233
+ debugLog('❌ CONNECTION REFRESH ERROR', { userId: user.id, error: errorMessage });
234
+ } finally {
235
+ setIsLoading(false);
236
+ debugLog('🔄 CONNECTION REFRESH FINISHED', { userId: user.id });
237
+ }
238
+ }, [service, user]);
239
+
240
+ // Memoize context value to prevent unnecessary re-renders
241
+ const contextValue: MessagingContextValue = React.useMemo(() => {
242
+ debugLog('💫 CONTEXT VALUE MEMOIZATION', {
243
+ hasService: !!service,
244
+ hasClient: !!client,
245
+ isConnected,
246
+ isLoading,
247
+ hasError: !!error,
248
+ capabilitiesKeys: Object.keys(capabilities),
249
+ customizationKeys: Object.keys(customization)
250
+ });
251
+
252
+ return {
253
+ service,
254
+ client,
255
+ isConnected,
256
+ isLoading,
257
+ error,
258
+ capabilities,
259
+ customization,
260
+ refreshConnection,
261
+ debug,
262
+ };
263
+ }, [service, client, isConnected, isLoading, error, capabilities, customization, refreshConnection, debug]);
264
+
265
+ debugLog('🔄 RENDER END', {
266
+ renderCount: renderCountRef.current,
267
+ willRenderChat: !!(client && isConnected),
268
+ contextValueReady: !!contextValue
269
+ });
270
+
271
+ return (
272
+ <MessagingContext.Provider value={contextValue}>
273
+ {client && isConnected ? (
274
+ <Chat client={client}>
275
+ {children}
276
+ </Chat>
277
+ ) : (
278
+ children
279
+ )}
280
+ </MessagingContext.Provider>
281
+ );
282
+ };
@@ -0,0 +1,157 @@
1
+ import React from 'react'
2
+ import { StreamChat } from 'stream-chat'
3
+ import { Chat } from 'stream-chat-react'
4
+
5
+ // Mock Stream Chat client for Storybook
6
+ const mockUser = {
7
+ id: 'storybook-user',
8
+ name: 'Storybook User',
9
+ image: 'https://i.pravatar.cc/150?img=1',
10
+ }
11
+
12
+ const createMockClient = () => {
13
+ const client = new StreamChat('mock-api-key', {
14
+ allowServerSideConnect: true,
15
+ })
16
+
17
+ // Mock the client methods
18
+ client.connectUser = async () => {
19
+ return { me: mockUser } as any
20
+ }
21
+
22
+ client.disconnectUser = async () => {
23
+ return Promise.resolve()
24
+ }
25
+
26
+ // @ts-ignore
27
+ client.userID = mockUser.id
28
+ // @ts-ignore
29
+ client.user = mockUser
30
+
31
+ return client
32
+ }
33
+
34
+ export const MockChatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
35
+ const [client] = React.useState(() => createMockClient())
36
+
37
+ React.useEffect(() => {
38
+ return () => {
39
+ client.disconnectUser().catch(console.error)
40
+ }
41
+ }, [client])
42
+
43
+ return <Chat client={client}>{children}</Chat>
44
+ }
45
+
46
+ // Create a mock chat provider with pre-populated channels
47
+ export const MockChatProviderWithChannels: React.FC<{
48
+ children: React.ReactNode;
49
+ channelCount?: number;
50
+ }> = ({ children, channelCount = 3 }) => {
51
+ const [client] = React.useState(() => {
52
+ const mockClient = createMockClient()
53
+
54
+ // Create mock channels
55
+ const mockChannels = Array.from({ length: channelCount }, (_, i) => {
56
+ const participant = mockParticipants[i % mockParticipants.length]
57
+
58
+ const mockChannel = {
59
+ id: `channel-${i + 1}`,
60
+ cid: `messaging:channel-${i + 1}`,
61
+ type: 'messaging',
62
+ _client: mockClient,
63
+ state: {
64
+ members: {
65
+ [mockUser.id]: {
66
+ user: mockUser,
67
+ user_id: mockUser.id,
68
+ },
69
+ [participant.id]: {
70
+ user: participant,
71
+ user_id: participant.id,
72
+ },
73
+ },
74
+ messages: [
75
+ {
76
+ id: `msg-${i}-1`,
77
+ text: `Hey! This is message ${i + 1}`,
78
+ created_at: new Date(Date.now() - 1000 * 60 * (i + 1)),
79
+ user: participant,
80
+ },
81
+ ],
82
+ unreadCount: i === 0 ? 2 : 0,
83
+ read: {},
84
+ },
85
+ // Mock channel methods
86
+ query: async () => ({ messages: [] }),
87
+ watch: async () => ({ messages: [] }),
88
+ }
89
+
90
+ return mockChannel
91
+ })
92
+
93
+ // @ts-ignore - Mock queryChannels method
94
+ mockClient.queryChannels = async () => mockChannels
95
+
96
+ return mockClient
97
+ })
98
+
99
+ React.useEffect(() => {
100
+ return () => {
101
+ client.disconnectUser().catch(console.error)
102
+ }
103
+ }, [client])
104
+
105
+ return <Chat client={client}>{children}</Chat>
106
+ }
107
+
108
+ // Mock participants for testing
109
+ export const mockParticipants = [
110
+ {
111
+ id: 'participant-1',
112
+ name: 'Alice Johnson',
113
+ email: 'alice@example.com',
114
+ image: 'https://i.pravatar.cc/150?img=2',
115
+ },
116
+ {
117
+ id: 'participant-2',
118
+ name: 'Bob Smith',
119
+ email: 'bob@example.com',
120
+ image: 'https://i.pravatar.cc/150?img=3',
121
+ },
122
+ {
123
+ id: 'participant-3',
124
+ name: 'Carol Williams',
125
+ email: 'carol@example.com',
126
+ image: 'https://i.pravatar.cc/150?img=4',
127
+ },
128
+ {
129
+ id: 'participant-4',
130
+ name: 'David Brown',
131
+ email: 'david@example.com',
132
+ image: 'https://i.pravatar.cc/150?img=5',
133
+ },
134
+ {
135
+ id: 'participant-5',
136
+ name: 'Emma Davis',
137
+ email: 'emma@example.com',
138
+ image: 'https://i.pravatar.cc/150?img=6',
139
+ },
140
+ ]
141
+
142
+ // Mock participant source for testing
143
+ export const mockParticipantSource = {
144
+ loadParticipants: async (options?: { search?: string; limit?: number }) => {
145
+ const searchTerm = options?.search || ''
146
+ const filtered = mockParticipants.filter(
147
+ (p) =>
148
+ p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
149
+ p.email.toLowerCase().includes(searchTerm.toLowerCase())
150
+ )
151
+ return {
152
+ participants: filtered,
153
+ hasMore: false,
154
+ }
155
+ },
156
+ }
157
+
@@ -0,0 +1,30 @@
1
+ import '@testing-library/jest-dom';
2
+ import { cleanup } from '@testing-library/react';
3
+ import { afterEach } from 'vitest';
4
+
5
+ // Cleanup after each test
6
+ afterEach(() => {
7
+ cleanup();
8
+ });
9
+
10
+ // Mock Stream Chat if needed
11
+ global.ResizeObserver = class ResizeObserver {
12
+ observe() {}
13
+ unobserve() {}
14
+ disconnect() {}
15
+ };
16
+
17
+ // Mock IntersectionObserver for virtualization
18
+ global.IntersectionObserver = class IntersectionObserver {
19
+ constructor() {}
20
+ observe() {}
21
+ unobserve() {}
22
+ disconnect() {}
23
+ takeRecords() {
24
+ return [];
25
+ }
26
+ root = null;
27
+ rootMargin = '';
28
+ thresholds = [];
29
+ };
30
+
@@ -0,0 +1,23 @@
1
+ import { render, RenderOptions, RenderResult } from '@testing-library/react';
2
+ import { ReactElement, ReactNode } from 'react';
3
+
4
+ /**
5
+ * Custom render function that wraps components with common providers
6
+ */
7
+ export function renderWithProviders(
8
+ ui: ReactElement,
9
+ options?: Omit<RenderOptions, 'wrapper'>
10
+ ): RenderResult {
11
+ function Wrapper({ children }: { children: ReactNode }) {
12
+ return <>{children}</>;
13
+ }
14
+
15
+ return render(ui, { wrapper: Wrapper, ...options });
16
+ }
17
+
18
+ /**
19
+ * Re-export everything from React Testing Library
20
+ */
21
+ export * from '@testing-library/react';
22
+ export { default as userEvent } from '@testing-library/user-event';
23
+