@sudobility/entity-components-rn 1.0.1

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.
@@ -0,0 +1,254 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ FlatList,
6
+ Pressable,
7
+ Image,
8
+ ActivityIndicator,
9
+ RefreshControl,
10
+ } from 'react-native';
11
+ import type { MemberListProps, Member, EntityRole } from './types';
12
+ import { DEFAULT_ROLE_CONFIGS } from './types';
13
+
14
+ /**
15
+ * Get role badge color classes
16
+ */
17
+ const getRoleBadgeClasses = (role: EntityRole): string => {
18
+ const colorMap: Record<EntityRole, string> = {
19
+ owner:
20
+ 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
21
+ admin: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
22
+ member: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
23
+ viewer: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
24
+ guest:
25
+ 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
26
+ };
27
+ return colorMap[role] || colorMap.member;
28
+ };
29
+
30
+ /**
31
+ * Get role label
32
+ */
33
+ const getRoleLabel = (role: EntityRole): string => {
34
+ const config = DEFAULT_ROLE_CONFIGS.find(c => c.role === role);
35
+ return config?.label || role;
36
+ };
37
+
38
+ /**
39
+ * Get status indicator color
40
+ */
41
+ const getStatusColor = (status?: string): string => {
42
+ switch (status) {
43
+ case 'active':
44
+ return 'bg-green-500';
45
+ case 'inactive':
46
+ return 'bg-gray-400';
47
+ case 'pending':
48
+ return 'bg-yellow-500';
49
+ default:
50
+ return 'bg-green-500';
51
+ }
52
+ };
53
+
54
+ /**
55
+ * MemberList - List of entity members with role badges
56
+ */
57
+ export const MemberList: React.FC<MemberListProps> = ({
58
+ members,
59
+ onMemberPress,
60
+ onRoleChange,
61
+ onRemoveMember,
62
+ currentUserRole,
63
+ canEditRoles = false,
64
+ canRemoveMembers = false,
65
+ emptyMessage = 'No members found',
66
+ emptyIcon,
67
+ loading = false,
68
+ refreshing = false,
69
+ onRefresh,
70
+ ListHeaderComponent,
71
+ ListFooterComponent,
72
+ className = '',
73
+ style,
74
+ testID,
75
+ }) => {
76
+ const canEditMember = (member: Member): boolean => {
77
+ if (!canEditRoles) return false;
78
+ if (!currentUserRole) return false;
79
+
80
+ // Owners can edit anyone except other owners
81
+ if (currentUserRole === 'owner' && member.role !== 'owner') return true;
82
+
83
+ // Admins can edit members, viewers, and guests
84
+ if (
85
+ currentUserRole === 'admin' &&
86
+ ['member', 'viewer', 'guest'].includes(member.role)
87
+ )
88
+ return true;
89
+
90
+ return false;
91
+ };
92
+
93
+ const canRemoveMember = (member: Member): boolean => {
94
+ if (!canRemoveMembers) return false;
95
+ if (!currentUserRole) return false;
96
+
97
+ // Cannot remove owners
98
+ if (member.role === 'owner') return false;
99
+
100
+ // Owners can remove anyone except other owners
101
+ if (currentUserRole === 'owner') return true;
102
+
103
+ // Admins can remove members, viewers, and guests
104
+ if (
105
+ currentUserRole === 'admin' &&
106
+ ['member', 'viewer', 'guest'].includes(member.role)
107
+ )
108
+ return true;
109
+
110
+ return false;
111
+ };
112
+
113
+ const renderItem = ({ item }: { item: Member }) => (
114
+ <Pressable
115
+ onPress={() => onMemberPress?.(item)}
116
+ disabled={!onMemberPress}
117
+ className={`
118
+ flex-row items-center p-4 rounded-xl mb-3
119
+ bg-white dark:bg-gray-800
120
+ border border-gray-200 dark:border-gray-700
121
+ active:opacity-80
122
+ `}
123
+ accessibilityRole='button'
124
+ accessibilityLabel={`Member: ${item.name}, Role: ${item.role}`}
125
+ >
126
+ {/* Avatar with status indicator */}
127
+ <View className='mr-3 relative'>
128
+ {item.avatarUrl ? (
129
+ <Image
130
+ source={{ uri: item.avatarUrl }}
131
+ className='w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600'
132
+ accessibilityIgnoresInvertColors
133
+ />
134
+ ) : (
135
+ <View className='w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 items-center justify-center'>
136
+ <Text className='text-lg font-semibold text-gray-600 dark:text-gray-300'>
137
+ {item.name.charAt(0).toUpperCase()}
138
+ </Text>
139
+ </View>
140
+ )}
141
+ {/* Status indicator */}
142
+ <View
143
+ className={`absolute bottom-0 right-0 w-3 h-3 rounded-full ${getStatusColor(item.status)} border-2 border-white dark:border-gray-800`}
144
+ />
145
+ </View>
146
+
147
+ {/* Content */}
148
+ <View className='flex-1'>
149
+ <View className='flex-row items-center flex-wrap'>
150
+ <Text
151
+ className='text-base font-semibold text-gray-900 dark:text-white mr-2'
152
+ numberOfLines={1}
153
+ >
154
+ {item.name}
155
+ </Text>
156
+ <View
157
+ className={`px-2 py-0.5 rounded-full ${getRoleBadgeClasses(item.role)}`}
158
+ >
159
+ <Text className='text-xs font-medium'>
160
+ {getRoleLabel(item.role)}
161
+ </Text>
162
+ </View>
163
+ </View>
164
+ <Text
165
+ className='text-sm text-gray-600 dark:text-gray-400'
166
+ numberOfLines={1}
167
+ >
168
+ {item.email}
169
+ </Text>
170
+ {item.joinedAt && (
171
+ <Text className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
172
+ Joined {new Date(item.joinedAt).toLocaleDateString()}
173
+ </Text>
174
+ )}
175
+ </View>
176
+
177
+ {/* Actions */}
178
+ <View className='flex-row items-center ml-2'>
179
+ {canEditMember(item) && onRoleChange && (
180
+ <Pressable
181
+ onPress={() => {}}
182
+ className='p-2 mr-1 active:opacity-60'
183
+ accessibilityRole='button'
184
+ accessibilityLabel='Edit role'
185
+ >
186
+ <Text className='text-blue-500 dark:text-blue-400 text-sm'>
187
+ Edit
188
+ </Text>
189
+ </Pressable>
190
+ )}
191
+ {canRemoveMember(item) && onRemoveMember && (
192
+ <Pressable
193
+ onPress={() => onRemoveMember(item)}
194
+ className='p-2 active:opacity-60'
195
+ accessibilityRole='button'
196
+ accessibilityLabel='Remove member'
197
+ >
198
+ <Text className='text-red-500 dark:text-red-400 text-sm'>
199
+ Remove
200
+ </Text>
201
+ </Pressable>
202
+ )}
203
+ </View>
204
+ </Pressable>
205
+ );
206
+
207
+ const renderEmpty = () => {
208
+ if (loading) {
209
+ return (
210
+ <View className='flex-1 items-center justify-center py-12'>
211
+ <ActivityIndicator size='large' color='#3B82F6' />
212
+ <Text className='text-gray-500 dark:text-gray-400 mt-4'>
213
+ Loading...
214
+ </Text>
215
+ </View>
216
+ );
217
+ }
218
+
219
+ return (
220
+ <View className='flex-1 items-center justify-center py-12'>
221
+ {emptyIcon}
222
+ <Text className='text-gray-500 dark:text-gray-400 text-center mt-2'>
223
+ {emptyMessage}
224
+ </Text>
225
+ </View>
226
+ );
227
+ };
228
+
229
+ return (
230
+ <View className={`flex-1 ${className}`} style={style} testID={testID}>
231
+ <FlatList
232
+ data={members}
233
+ renderItem={renderItem}
234
+ keyExtractor={item => item.id}
235
+ ListHeaderComponent={ListHeaderComponent}
236
+ ListFooterComponent={ListFooterComponent}
237
+ ListEmptyComponent={renderEmpty}
238
+ contentContainerStyle={{ flexGrow: 1, padding: 16 }}
239
+ showsVerticalScrollIndicator={false}
240
+ refreshControl={
241
+ onRefresh ? (
242
+ <RefreshControl
243
+ refreshing={refreshing}
244
+ onRefresh={onRefresh}
245
+ tintColor='#3B82F6'
246
+ />
247
+ ) : undefined
248
+ }
249
+ />
250
+ </View>
251
+ );
252
+ };
253
+
254
+ export default MemberList;
@@ -0,0 +1,174 @@
1
+ import React, { useState } from 'react';
2
+ import { View, Text, Pressable, Modal } from 'react-native';
3
+ import type { MemberRoleSelectorProps, EntityRole } from './types';
4
+ import { DEFAULT_ROLE_CONFIGS } from './types';
5
+
6
+ /**
7
+ * Get role badge color classes
8
+ */
9
+ const getRoleBadgeClasses = (role: EntityRole): string => {
10
+ const colorMap: Record<EntityRole, string> = {
11
+ owner:
12
+ 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
13
+ admin: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
14
+ member: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
15
+ viewer: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
16
+ guest:
17
+ 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
18
+ };
19
+ return colorMap[role] || colorMap.member;
20
+ };
21
+
22
+ /**
23
+ * MemberRoleSelector - Role selection component
24
+ */
25
+ export const MemberRoleSelector: React.FC<MemberRoleSelectorProps> = ({
26
+ selectedRole,
27
+ onRoleChange,
28
+ availableRoles = ['admin', 'member', 'viewer', 'guest'],
29
+ disabled = false,
30
+ showDescriptions = true,
31
+ className = '',
32
+ style,
33
+ testID,
34
+ }) => {
35
+ const [isOpen, setIsOpen] = useState(false);
36
+
37
+ const handleOpen = () => {
38
+ if (!disabled) {
39
+ setIsOpen(true);
40
+ }
41
+ };
42
+
43
+ const handleClose = () => {
44
+ setIsOpen(false);
45
+ };
46
+
47
+ const handleSelect = (role: EntityRole) => {
48
+ onRoleChange(role);
49
+ handleClose();
50
+ };
51
+
52
+ const selectedConfig = DEFAULT_ROLE_CONFIGS.find(
53
+ c => c.role === selectedRole
54
+ );
55
+ const availableConfigs = DEFAULT_ROLE_CONFIGS.filter(c =>
56
+ availableRoles.includes(c.role)
57
+ );
58
+
59
+ return (
60
+ <View className={className} style={style} testID={testID}>
61
+ {/* Trigger Button */}
62
+ <Pressable
63
+ onPress={handleOpen}
64
+ disabled={disabled}
65
+ className={`
66
+ flex-row items-center justify-between px-4 py-3 rounded-xl
67
+ bg-white dark:bg-gray-800
68
+ border border-gray-200 dark:border-gray-700
69
+ ${disabled ? 'opacity-50' : ''}
70
+ active:opacity-80
71
+ `}
72
+ accessibilityRole='button'
73
+ accessibilityLabel={`Selected role: ${selectedConfig?.label || selectedRole}`}
74
+ accessibilityState={{ disabled }}
75
+ >
76
+ <View className='flex-row items-center'>
77
+ <View
78
+ className={`px-3 py-1 rounded-full mr-2 ${getRoleBadgeClasses(selectedRole)}`}
79
+ >
80
+ <Text className='text-sm font-medium'>
81
+ {selectedConfig?.label || selectedRole}
82
+ </Text>
83
+ </View>
84
+ {showDescriptions && selectedConfig && (
85
+ <Text
86
+ className='text-sm text-gray-500 dark:text-gray-400 flex-1'
87
+ numberOfLines={1}
88
+ >
89
+ {selectedConfig.description}
90
+ </Text>
91
+ )}
92
+ </View>
93
+ <Text className='text-gray-400 dark:text-gray-500 text-lg ml-2'>▼</Text>
94
+ </Pressable>
95
+
96
+ {/* Dropdown Modal */}
97
+ <Modal
98
+ visible={isOpen}
99
+ transparent
100
+ animationType='fade'
101
+ onRequestClose={handleClose}
102
+ >
103
+ <Pressable
104
+ onPress={handleClose}
105
+ className='flex-1 bg-black/50 justify-center px-4'
106
+ >
107
+ <Pressable
108
+ onPress={e => e.stopPropagation()}
109
+ className='bg-white dark:bg-gray-800 rounded-2xl overflow-hidden'
110
+ >
111
+ {/* Header */}
112
+ <View className='px-4 py-3 border-b border-gray-200 dark:border-gray-700'>
113
+ <Text className='text-lg font-semibold text-gray-900 dark:text-white'>
114
+ Select Role
115
+ </Text>
116
+ </View>
117
+
118
+ {/* Role Options */}
119
+ {availableConfigs.map(config => (
120
+ <Pressable
121
+ key={config.role}
122
+ onPress={() => handleSelect(config.role)}
123
+ className={`
124
+ px-4 py-4 border-b border-gray-100 dark:border-gray-700
125
+ ${selectedRole === config.role ? 'bg-blue-50 dark:bg-blue-900/30' : ''}
126
+ active:bg-gray-100 dark:active:bg-gray-700
127
+ `}
128
+ accessibilityRole='button'
129
+ accessibilityLabel={`${config.label}: ${config.description}`}
130
+ accessibilityState={{ selected: selectedRole === config.role }}
131
+ >
132
+ <View className='flex-row items-center justify-between'>
133
+ <View className='flex-1'>
134
+ <View className='flex-row items-center'>
135
+ <View
136
+ className={`px-3 py-1 rounded-full ${getRoleBadgeClasses(config.role)}`}
137
+ >
138
+ <Text className='text-sm font-medium'>
139
+ {config.label}
140
+ </Text>
141
+ </View>
142
+ </View>
143
+ {showDescriptions && (
144
+ <Text className='text-sm text-gray-500 dark:text-gray-400 mt-2'>
145
+ {config.description}
146
+ </Text>
147
+ )}
148
+ </View>
149
+ {selectedRole === config.role && (
150
+ <Text className='text-blue-500 dark:text-blue-400 text-lg ml-2'>
151
+
152
+ </Text>
153
+ )}
154
+ </View>
155
+ </Pressable>
156
+ ))}
157
+
158
+ {/* Cancel Button */}
159
+ <Pressable
160
+ onPress={handleClose}
161
+ className='px-4 py-3 items-center active:bg-gray-100 dark:active:bg-gray-700'
162
+ >
163
+ <Text className='text-blue-500 dark:text-blue-400 font-medium'>
164
+ Cancel
165
+ </Text>
166
+ </Pressable>
167
+ </Pressable>
168
+ </Pressable>
169
+ </Modal>
170
+ </View>
171
+ );
172
+ };
173
+
174
+ export default MemberRoleSelector;
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react-native';
3
+ import { EntityCard } from '../EntityCard';
4
+ import type { Entity } from '../types';
5
+
6
+ const baseEntity: Entity = {
7
+ id: '1',
8
+ name: 'Acme Corp',
9
+ description: 'A test organization',
10
+ role: 'admin',
11
+ memberCount: 5,
12
+ };
13
+
14
+ describe('EntityCard', () => {
15
+ it('renders entity name', () => {
16
+ render(<EntityCard entity={baseEntity} />);
17
+ expect(screen.getByText('Acme Corp')).toBeTruthy();
18
+ });
19
+
20
+ it('renders avatar initial when no avatarUrl', () => {
21
+ render(<EntityCard entity={baseEntity} />);
22
+ expect(screen.getByText('A')).toBeTruthy();
23
+ });
24
+
25
+ it('renders description when showDescription is true', () => {
26
+ render(<EntityCard entity={baseEntity} showDescription />);
27
+ expect(screen.getByText('A test organization')).toBeTruthy();
28
+ });
29
+
30
+ it('hides description when showDescription is false', () => {
31
+ render(<EntityCard entity={baseEntity} showDescription={false} />);
32
+ expect(screen.queryByText('A test organization')).toBeNull();
33
+ });
34
+
35
+ it('renders role badge when showRole is true and entity has role', () => {
36
+ render(<EntityCard entity={baseEntity} showRole />);
37
+ expect(screen.getByText('Admin')).toBeTruthy();
38
+ });
39
+
40
+ it('hides role badge when showRole is false', () => {
41
+ render(<EntityCard entity={baseEntity} showRole={false} />);
42
+ expect(screen.queryByText('Admin')).toBeNull();
43
+ });
44
+
45
+ it('renders member count when showMemberCount is true', () => {
46
+ render(<EntityCard entity={baseEntity} showMemberCount />);
47
+ expect(screen.getByText(/5\s+members/)).toBeTruthy();
48
+ });
49
+
50
+ it('shows singular "member" for count of 1', () => {
51
+ const entity = { ...baseEntity, memberCount: 1 };
52
+ render(<EntityCard entity={entity} showMemberCount />);
53
+ expect(screen.getByText(/1\s+member$/)).toBeTruthy();
54
+ });
55
+
56
+ it('calls onPress with entity when pressed', () => {
57
+ const onPress = jest.fn();
58
+ render(<EntityCard entity={baseEntity} onPress={onPress} />);
59
+ fireEvent.press(screen.getByRole('button'));
60
+ expect(onPress).toHaveBeenCalledWith(baseEntity);
61
+ });
62
+
63
+ it('calls onLongPress with entity when long-pressed', () => {
64
+ const onLongPress = jest.fn();
65
+ render(<EntityCard entity={baseEntity} onLongPress={onLongPress} />);
66
+ fireEvent(screen.getByRole('button'), 'longPress');
67
+ expect(onLongPress).toHaveBeenCalledWith(baseEntity);
68
+ });
69
+
70
+ it('sets selected accessibility state when selected', () => {
71
+ render(<EntityCard entity={baseEntity} selected onPress={jest.fn()} />);
72
+ const button = screen.getByRole('button');
73
+ expect(button.props.accessibilityState).toEqual(
74
+ expect.objectContaining({ selected: true })
75
+ );
76
+ });
77
+
78
+ it('sets accessibility label with entity name', () => {
79
+ render(<EntityCard entity={baseEntity} onPress={jest.fn()} />);
80
+ const button = screen.getByRole('button');
81
+ expect(button.props.accessibilityLabel).toBe('Entity: Acme Corp');
82
+ });
83
+
84
+ it('renders chevron when onPress is provided', () => {
85
+ render(<EntityCard entity={baseEntity} onPress={jest.fn()} />);
86
+ expect(screen.getByText('\u203A')).toBeTruthy();
87
+ });
88
+
89
+ it('does not render chevron when no onPress', () => {
90
+ render(<EntityCard entity={baseEntity} />);
91
+ expect(screen.queryByText('\u203A')).toBeNull();
92
+ });
93
+
94
+ it('renders all role types without crashing', () => {
95
+ const roles = ['owner', 'admin', 'member', 'viewer', 'guest'] as const;
96
+ roles.forEach(role => {
97
+ const entity = { ...baseEntity, role };
98
+ const { unmount } = render(<EntityCard entity={entity} showRole />);
99
+ expect(screen.getByText('Acme Corp')).toBeTruthy();
100
+ unmount();
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,153 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
3
+ import { InvitationForm } from '../InvitationForm';
4
+
5
+ describe('InvitationForm', () => {
6
+ it('renders email input and submit button', () => {
7
+ render(<InvitationForm onSubmit={jest.fn()} />);
8
+ expect(screen.getByLabelText('Email address input')).toBeTruthy();
9
+ expect(screen.getByText('Send Invitation')).toBeTruthy();
10
+ });
11
+
12
+ it('renders custom placeholder text', () => {
13
+ render(
14
+ <InvitationForm onSubmit={jest.fn()} placeholder='user@example.com' />
15
+ );
16
+ expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy();
17
+ });
18
+
19
+ it('renders custom submit label', () => {
20
+ render(<InvitationForm onSubmit={jest.fn()} submitLabel='Invite' />);
21
+ expect(screen.getByText('Invite')).toBeTruthy();
22
+ });
23
+
24
+ it('disables submit button when email is empty', () => {
25
+ render(<InvitationForm onSubmit={jest.fn()} />);
26
+ const button = screen.getByLabelText('Send Invitation');
27
+ expect(button.props.accessibilityState).toEqual(
28
+ expect.objectContaining({ disabled: true })
29
+ );
30
+ });
31
+
32
+ it('shows error for invalid email on submit', async () => {
33
+ render(<InvitationForm onSubmit={jest.fn()} />);
34
+ fireEvent.changeText(
35
+ screen.getByLabelText('Email address input'),
36
+ 'notanemail'
37
+ );
38
+ fireEvent.press(screen.getByLabelText('Send Invitation'));
39
+ await waitFor(() => {
40
+ expect(
41
+ screen.getByText('Please enter a valid email address')
42
+ ).toBeTruthy();
43
+ });
44
+ });
45
+
46
+ it('clears error when user types after validation failure', async () => {
47
+ render(<InvitationForm onSubmit={jest.fn()} />);
48
+ // Type invalid email and submit to trigger error
49
+ fireEvent.changeText(
50
+ screen.getByLabelText('Email address input'),
51
+ 'notanemail'
52
+ );
53
+ fireEvent.press(screen.getByLabelText('Send Invitation'));
54
+ await waitFor(() => {
55
+ expect(
56
+ screen.getByText('Please enter a valid email address')
57
+ ).toBeTruthy();
58
+ });
59
+ // Type something new to clear error
60
+ fireEvent.changeText(
61
+ screen.getByLabelText('Email address input'),
62
+ 'new-text'
63
+ );
64
+ expect(
65
+ screen.queryByText('Please enter a valid email address')
66
+ ).toBeNull();
67
+ });
68
+
69
+ it('calls onSubmit with email and default role on valid submission', async () => {
70
+ const onSubmit = jest.fn();
71
+ render(<InvitationForm onSubmit={onSubmit} />);
72
+ fireEvent.changeText(
73
+ screen.getByLabelText('Email address input'),
74
+ 'test@example.com'
75
+ );
76
+ fireEvent.press(screen.getByLabelText('Send Invitation'));
77
+ await waitFor(() => {
78
+ expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'member');
79
+ });
80
+ });
81
+
82
+ it('uses defaultRole when specified', async () => {
83
+ const onSubmit = jest.fn();
84
+ render(<InvitationForm onSubmit={onSubmit} defaultRole='viewer' />);
85
+ fireEvent.changeText(
86
+ screen.getByLabelText('Email address input'),
87
+ 'test@example.com'
88
+ );
89
+ fireEvent.press(screen.getByLabelText('Send Invitation'));
90
+ await waitFor(() => {
91
+ expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'viewer');
92
+ });
93
+ });
94
+
95
+ it('clears form after successful submission', async () => {
96
+ const onSubmit = jest.fn().mockResolvedValue(undefined);
97
+ render(<InvitationForm onSubmit={onSubmit} />);
98
+ const input = screen.getByLabelText('Email address input');
99
+ fireEvent.changeText(input, 'test@example.com');
100
+ fireEvent.press(screen.getByLabelText('Send Invitation'));
101
+ await waitFor(() => {
102
+ expect(input.props.value).toBe('');
103
+ });
104
+ });
105
+
106
+ it('shows error message when onSubmit throws', async () => {
107
+ const onSubmit = jest.fn().mockRejectedValue(new Error('Network error'));
108
+ render(<InvitationForm onSubmit={onSubmit} />);
109
+ fireEvent.changeText(
110
+ screen.getByLabelText('Email address input'),
111
+ 'test@example.com'
112
+ );
113
+ fireEvent.press(screen.getByLabelText('Send Invitation'));
114
+ await waitFor(() => {
115
+ expect(screen.getByText('Network error')).toBeTruthy();
116
+ });
117
+ });
118
+
119
+ it('shows generic error when onSubmit throws non-Error', async () => {
120
+ const onSubmit = jest.fn().mockRejectedValue('unknown');
121
+ render(<InvitationForm onSubmit={onSubmit} />);
122
+ fireEvent.changeText(
123
+ screen.getByLabelText('Email address input'),
124
+ 'test@example.com'
125
+ );
126
+ fireEvent.press(screen.getByLabelText('Send Invitation'));
127
+ await waitFor(() => {
128
+ expect(screen.getByText('Failed to send invitation')).toBeTruthy();
129
+ });
130
+ });
131
+
132
+ it('trims email before submitting', async () => {
133
+ const onSubmit = jest.fn();
134
+ render(<InvitationForm onSubmit={onSubmit} />);
135
+ fireEvent.changeText(
136
+ screen.getByLabelText('Email address input'),
137
+ ' test@example.com '
138
+ );
139
+ fireEvent.press(screen.getByLabelText('Send Invitation'));
140
+ await waitFor(() => {
141
+ expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'member');
142
+ });
143
+ });
144
+
145
+ it('renders help text', () => {
146
+ render(<InvitationForm onSubmit={jest.fn()} />);
147
+ expect(
148
+ screen.getByText(
149
+ 'The invitee will receive an email with instructions to join.'
150
+ )
151
+ ).toBeTruthy();
152
+ });
153
+ });