@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.
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1509 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +63 -0
- package/src/EntityCard.tsx +137 -0
- package/src/EntityList.tsx +96 -0
- package/src/EntitySelector.tsx +200 -0
- package/src/InvitationForm.tsx +163 -0
- package/src/InvitationList.tsx +240 -0
- package/src/MemberList.tsx +254 -0
- package/src/MemberRoleSelector.tsx +174 -0
- package/src/__tests__/EntityCard.test.tsx +103 -0
- package/src/__tests__/InvitationForm.test.tsx +153 -0
- package/src/__tests__/MemberRoleSelector.test.tsx +125 -0
- package/src/index.ts +29 -0
- package/src/nativewind.d.ts +27 -0
- package/src/types.ts +226 -0
|
@@ -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
|
+
});
|