@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,200 @@
1
+ import React, { useState } from 'react';
2
+ import { View, Text, Pressable, Modal, FlatList, Image } from 'react-native';
3
+ import type { EntitySelectorProps, Entity } from './types';
4
+
5
+ /**
6
+ * EntitySelector - Dropdown/Pressable selector for switching entities
7
+ */
8
+ export const EntitySelector: React.FC<EntitySelectorProps> = ({
9
+ entities,
10
+ selectedEntity,
11
+ onSelect,
12
+ placeholder = 'Select an entity',
13
+ disabled = false,
14
+ loading = false,
15
+ showAvatars = true,
16
+ showRoles = false,
17
+ className = '',
18
+ style,
19
+ testID,
20
+ }) => {
21
+ const [isOpen, setIsOpen] = useState(false);
22
+
23
+ const handleOpen = () => {
24
+ if (!disabled && !loading) {
25
+ setIsOpen(true);
26
+ }
27
+ };
28
+
29
+ const handleClose = () => {
30
+ setIsOpen(false);
31
+ };
32
+
33
+ const handleSelect = (entity: Entity) => {
34
+ onSelect(entity);
35
+ handleClose();
36
+ };
37
+
38
+ const renderAvatar = (entity: Entity, size: 'sm' | 'md' = 'md') => {
39
+ const sizeClasses = size === 'sm' ? 'w-8 h-8' : 'w-10 h-10';
40
+ const textSize = size === 'sm' ? 'text-sm' : 'text-base';
41
+
42
+ if (entity.avatarUrl) {
43
+ return (
44
+ <Image
45
+ source={{ uri: entity.avatarUrl }}
46
+ className={`${sizeClasses} rounded-full bg-gray-200 dark:bg-gray-600`}
47
+ accessibilityIgnoresInvertColors
48
+ />
49
+ );
50
+ }
51
+
52
+ return (
53
+ <View
54
+ className={`${sizeClasses} rounded-full bg-gray-200 dark:bg-gray-600 items-center justify-center`}
55
+ >
56
+ <Text
57
+ className={`${textSize} font-semibold text-gray-600 dark:text-gray-300`}
58
+ >
59
+ {entity.name.charAt(0).toUpperCase()}
60
+ </Text>
61
+ </View>
62
+ );
63
+ };
64
+
65
+ const renderItem = ({ item }: { item: Entity }) => (
66
+ <Pressable
67
+ onPress={() => handleSelect(item)}
68
+ className={`
69
+ flex-row items-center px-4 py-3
70
+ ${selectedEntity?.id === item.id ? 'bg-blue-50 dark:bg-blue-900/30' : ''}
71
+ active:bg-gray-100 dark:active:bg-gray-700
72
+ `}
73
+ accessibilityRole='button'
74
+ accessibilityLabel={`Select ${item.name}`}
75
+ accessibilityState={{ selected: selectedEntity?.id === item.id }}
76
+ >
77
+ {showAvatars && <View className='mr-3'>{renderAvatar(item, 'sm')}</View>}
78
+ <View className='flex-1'>
79
+ <Text
80
+ className='text-base text-gray-900 dark:text-white'
81
+ numberOfLines={1}
82
+ >
83
+ {item.name}
84
+ </Text>
85
+ {showRoles && item.role && (
86
+ <Text className='text-xs text-gray-500 dark:text-gray-400 capitalize'>
87
+ {item.role}
88
+ </Text>
89
+ )}
90
+ </View>
91
+ {selectedEntity?.id === item.id && (
92
+ <Text className='text-blue-500 dark:text-blue-400 text-lg'>✓</Text>
93
+ )}
94
+ </Pressable>
95
+ );
96
+
97
+ return (
98
+ <View className={className} style={style} testID={testID}>
99
+ {/* Trigger Button */}
100
+ <Pressable
101
+ onPress={handleOpen}
102
+ disabled={disabled || loading}
103
+ className={`
104
+ flex-row items-center px-4 py-3 rounded-xl
105
+ bg-white dark:bg-gray-800
106
+ border border-gray-200 dark:border-gray-700
107
+ ${disabled ? 'opacity-50' : ''}
108
+ active:opacity-80
109
+ `}
110
+ accessibilityRole='button'
111
+ accessibilityLabel={
112
+ selectedEntity ? `Selected: ${selectedEntity.name}` : placeholder
113
+ }
114
+ accessibilityState={{ disabled }}
115
+ >
116
+ {loading ? (
117
+ <View className='flex-row items-center'>
118
+ <Text className='text-gray-500 dark:text-gray-400'>Loading...</Text>
119
+ </View>
120
+ ) : selectedEntity ? (
121
+ <>
122
+ {showAvatars && (
123
+ <View className='mr-3'>{renderAvatar(selectedEntity)}</View>
124
+ )}
125
+ <View className='flex-1'>
126
+ <Text
127
+ className='text-base font-medium text-gray-900 dark:text-white'
128
+ numberOfLines={1}
129
+ >
130
+ {selectedEntity.name}
131
+ </Text>
132
+ {showRoles && selectedEntity.role && (
133
+ <Text className='text-xs text-gray-500 dark:text-gray-400 capitalize'>
134
+ {selectedEntity.role}
135
+ </Text>
136
+ )}
137
+ </View>
138
+ </>
139
+ ) : (
140
+ <Text className='flex-1 text-gray-500 dark:text-gray-400'>
141
+ {placeholder}
142
+ </Text>
143
+ )}
144
+ <Text className='text-gray-400 dark:text-gray-500 text-lg ml-2'>▼</Text>
145
+ </Pressable>
146
+
147
+ {/* Dropdown Modal */}
148
+ <Modal
149
+ visible={isOpen}
150
+ transparent
151
+ animationType='fade'
152
+ onRequestClose={handleClose}
153
+ >
154
+ <Pressable
155
+ onPress={handleClose}
156
+ className='flex-1 bg-black/50 justify-center px-4'
157
+ >
158
+ <Pressable
159
+ onPress={e => e.stopPropagation()}
160
+ className='bg-white dark:bg-gray-800 rounded-2xl overflow-hidden max-h-96'
161
+ >
162
+ {/* Header */}
163
+ <View className='px-4 py-3 border-b border-gray-200 dark:border-gray-700'>
164
+ <Text className='text-lg font-semibold text-gray-900 dark:text-white'>
165
+ Select Entity
166
+ </Text>
167
+ </View>
168
+
169
+ {/* List */}
170
+ <FlatList
171
+ data={entities}
172
+ renderItem={renderItem}
173
+ keyExtractor={item => item.id}
174
+ showsVerticalScrollIndicator={false}
175
+ ListEmptyComponent={
176
+ <View className='py-8 items-center'>
177
+ <Text className='text-gray-500 dark:text-gray-400'>
178
+ No entities available
179
+ </Text>
180
+ </View>
181
+ }
182
+ />
183
+
184
+ {/* Cancel Button */}
185
+ <Pressable
186
+ onPress={handleClose}
187
+ className='px-4 py-3 border-t border-gray-200 dark:border-gray-700 items-center active:bg-gray-100 dark:active:bg-gray-700'
188
+ >
189
+ <Text className='text-blue-500 dark:text-blue-400 font-medium'>
190
+ Cancel
191
+ </Text>
192
+ </Pressable>
193
+ </Pressable>
194
+ </Pressable>
195
+ </Modal>
196
+ </View>
197
+ );
198
+ };
199
+
200
+ export default EntitySelector;
@@ -0,0 +1,163 @@
1
+ import React, { useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ Pressable,
7
+ ActivityIndicator,
8
+ } from 'react-native';
9
+ import type { InvitationFormProps, EntityRole } from './types';
10
+ import { MemberRoleSelector } from './MemberRoleSelector';
11
+
12
+ /**
13
+ * Validate email format
14
+ */
15
+ const isValidEmail = (email: string): boolean => {
16
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
17
+ return emailRegex.test(email);
18
+ };
19
+
20
+ /**
21
+ * InvitationForm - Form for inviting members with email input and role selector
22
+ */
23
+ export const InvitationForm: React.FC<InvitationFormProps> = ({
24
+ onSubmit,
25
+ availableRoles = ['admin', 'member', 'viewer', 'guest'],
26
+ defaultRole = 'member',
27
+ loading = false,
28
+ disabled = false,
29
+ placeholder = 'Enter email address',
30
+ submitLabel = 'Send Invitation',
31
+ className = '',
32
+ style,
33
+ testID,
34
+ }) => {
35
+ const [email, setEmail] = useState('');
36
+ const [selectedRole, setSelectedRole] = useState<EntityRole>(defaultRole);
37
+ const [error, setError] = useState<string | null>(null);
38
+ const [isSubmitting, setIsSubmitting] = useState(false);
39
+
40
+ const handleEmailChange = (text: string) => {
41
+ setEmail(text);
42
+ if (error) {
43
+ setError(null);
44
+ }
45
+ };
46
+
47
+ const handleSubmit = async () => {
48
+ // Validate email
49
+ const trimmedEmail = email.trim();
50
+
51
+ if (!trimmedEmail) {
52
+ setError('Email address is required');
53
+ return;
54
+ }
55
+
56
+ if (!isValidEmail(trimmedEmail)) {
57
+ setError('Please enter a valid email address');
58
+ return;
59
+ }
60
+
61
+ setIsSubmitting(true);
62
+ setError(null);
63
+
64
+ try {
65
+ await onSubmit(trimmedEmail, selectedRole);
66
+ // Clear form on success
67
+ setEmail('');
68
+ setSelectedRole(defaultRole);
69
+ } catch (err) {
70
+ setError(
71
+ err instanceof Error ? err.message : 'Failed to send invitation'
72
+ );
73
+ } finally {
74
+ setIsSubmitting(false);
75
+ }
76
+ };
77
+
78
+ const isDisabled = disabled || loading || isSubmitting;
79
+ const canSubmit = email.trim().length > 0 && !isDisabled;
80
+
81
+ return (
82
+ <View className={`${className}`} style={style} testID={testID}>
83
+ {/* Email Input */}
84
+ <View className='mb-4'>
85
+ <Text className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
86
+ Email Address
87
+ </Text>
88
+ <TextInput
89
+ value={email}
90
+ onChangeText={handleEmailChange}
91
+ placeholder={placeholder}
92
+ placeholderTextColor='#9CA3AF'
93
+ keyboardType='email-address'
94
+ autoCapitalize='none'
95
+ autoCorrect={false}
96
+ autoComplete='email'
97
+ editable={!isDisabled}
98
+ className={`
99
+ px-4 py-3 rounded-xl
100
+ bg-white dark:bg-gray-800
101
+ border ${error ? 'border-red-500' : 'border-gray-200 dark:border-gray-700'}
102
+ text-gray-900 dark:text-white
103
+ ${isDisabled ? 'opacity-50' : ''}
104
+ `}
105
+ accessibilityLabel='Email address input'
106
+ accessibilityHint='Enter the email address of the person you want to invite'
107
+ />
108
+ {error && (
109
+ <Text className='text-sm text-red-500 dark:text-red-400 mt-1'>
110
+ {error}
111
+ </Text>
112
+ )}
113
+ </View>
114
+
115
+ {/* Role Selector */}
116
+ <View className='mb-4'>
117
+ <Text className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
118
+ Role
119
+ </Text>
120
+ <MemberRoleSelector
121
+ selectedRole={selectedRole}
122
+ onRoleChange={setSelectedRole}
123
+ availableRoles={availableRoles}
124
+ disabled={isDisabled}
125
+ showDescriptions
126
+ />
127
+ </View>
128
+
129
+ {/* Submit Button */}
130
+ <Pressable
131
+ onPress={handleSubmit}
132
+ disabled={!canSubmit}
133
+ className={`
134
+ flex-row items-center justify-center px-6 py-4 rounded-xl
135
+ ${canSubmit ? 'bg-blue-500 dark:bg-blue-600 active:bg-blue-600 dark:active:bg-blue-700' : 'bg-gray-300 dark:bg-gray-600'}
136
+ `}
137
+ accessibilityRole='button'
138
+ accessibilityLabel={submitLabel}
139
+ accessibilityState={{ disabled: !canSubmit }}
140
+ >
141
+ {isSubmitting ? (
142
+ <>
143
+ <ActivityIndicator size='small' color='#FFFFFF' />
144
+ <Text className='text-white font-semibold ml-2'>Sending...</Text>
145
+ </>
146
+ ) : (
147
+ <Text
148
+ className={`font-semibold ${canSubmit ? 'text-white' : 'text-gray-500 dark:text-gray-400'}`}
149
+ >
150
+ {submitLabel}
151
+ </Text>
152
+ )}
153
+ </Pressable>
154
+
155
+ {/* Help Text */}
156
+ <Text className='text-xs text-gray-500 dark:text-gray-400 mt-3 text-center'>
157
+ The invitee will receive an email with instructions to join.
158
+ </Text>
159
+ </View>
160
+ );
161
+ };
162
+
163
+ export default InvitationForm;
@@ -0,0 +1,240 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ FlatList,
6
+ Pressable,
7
+ ActivityIndicator,
8
+ RefreshControl,
9
+ } from 'react-native';
10
+ import type { InvitationListProps, Invitation, EntityRole } from './types';
11
+ import { DEFAULT_ROLE_CONFIGS } from './types';
12
+
13
+ /**
14
+ * Get role badge color classes
15
+ */
16
+ const getRoleBadgeClasses = (role: EntityRole): string => {
17
+ const colorMap: Record<EntityRole, string> = {
18
+ owner:
19
+ 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
20
+ admin: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
21
+ member: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
22
+ viewer: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
23
+ guest:
24
+ 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
25
+ };
26
+ return colorMap[role] || colorMap.member;
27
+ };
28
+
29
+ /**
30
+ * Get role label
31
+ */
32
+ const getRoleLabel = (role: EntityRole): string => {
33
+ const config = DEFAULT_ROLE_CONFIGS.find(c => c.role === role);
34
+ return config?.label || role;
35
+ };
36
+
37
+ /**
38
+ * Get status badge classes
39
+ */
40
+ const getStatusBadgeClasses = (status: Invitation['status']): string => {
41
+ const colorMap: Record<Invitation['status'], string> = {
42
+ pending:
43
+ 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
44
+ accepted:
45
+ 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
46
+ declined: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
47
+ expired: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
48
+ };
49
+ return colorMap[status] || colorMap.pending;
50
+ };
51
+
52
+ /**
53
+ * Get status label
54
+ */
55
+ const getStatusLabel = (status: Invitation['status']): string => {
56
+ return status.charAt(0).toUpperCase() + status.slice(1);
57
+ };
58
+
59
+ /**
60
+ * Format relative time
61
+ */
62
+ const formatRelativeTime = (dateString: string): string => {
63
+ const date = new Date(dateString);
64
+ const now = new Date();
65
+ const diffMs = now.getTime() - date.getTime();
66
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
67
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
68
+ const diffMinutes = Math.floor(diffMs / (1000 * 60));
69
+
70
+ if (diffDays > 0) {
71
+ return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
72
+ } else if (diffHours > 0) {
73
+ return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
74
+ } else if (diffMinutes > 0) {
75
+ return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
76
+ } else {
77
+ return 'Just now';
78
+ }
79
+ };
80
+
81
+ /**
82
+ * InvitationList - List of pending invitations
83
+ */
84
+ export const InvitationList: React.FC<InvitationListProps> = ({
85
+ invitations,
86
+ onResend,
87
+ onCancel,
88
+ canResend = true,
89
+ canCancel = true,
90
+ emptyMessage = 'No pending invitations',
91
+ emptyIcon,
92
+ loading = false,
93
+ refreshing = false,
94
+ onRefresh,
95
+ ListHeaderComponent,
96
+ ListFooterComponent,
97
+ className = '',
98
+ style,
99
+ testID,
100
+ }) => {
101
+ const renderItem = ({ item }: { item: Invitation }) => {
102
+ const isPending = item.status === 'pending';
103
+ const showResend = canResend && onResend && isPending;
104
+ const showCancel = canCancel && onCancel && isPending;
105
+
106
+ return (
107
+ <View
108
+ className={`
109
+ p-4 rounded-xl mb-3
110
+ bg-white dark:bg-gray-800
111
+ border border-gray-200 dark:border-gray-700
112
+ `}
113
+ accessibilityLabel={`Invitation to ${item.email}, Status: ${item.status}`}
114
+ >
115
+ {/* Header */}
116
+ <View className='flex-row items-center justify-between mb-2'>
117
+ <Text
118
+ className='text-base font-semibold text-gray-900 dark:text-white flex-1'
119
+ numberOfLines={1}
120
+ >
121
+ {item.email}
122
+ </Text>
123
+ <View
124
+ className={`px-2 py-0.5 rounded-full ${getStatusBadgeClasses(item.status)}`}
125
+ >
126
+ <Text className='text-xs font-medium'>
127
+ {getStatusLabel(item.status)}
128
+ </Text>
129
+ </View>
130
+ </View>
131
+
132
+ {/* Role and Time */}
133
+ <View className='flex-row items-center mb-2'>
134
+ <View
135
+ className={`px-2 py-0.5 rounded-full mr-2 ${getRoleBadgeClasses(item.role)}`}
136
+ >
137
+ <Text className='text-xs font-medium'>
138
+ {getRoleLabel(item.role)}
139
+ </Text>
140
+ </View>
141
+ <Text className='text-xs text-gray-500 dark:text-gray-400'>
142
+ Invited {formatRelativeTime(item.invitedAt)}
143
+ </Text>
144
+ </View>
145
+
146
+ {/* Invited by */}
147
+ {item.invitedBy && (
148
+ <Text className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
149
+ by {item.invitedBy}
150
+ </Text>
151
+ )}
152
+
153
+ {/* Expiration warning */}
154
+ {item.expiresAt && isPending && (
155
+ <Text className='text-xs text-yellow-600 dark:text-yellow-400 mb-2'>
156
+ Expires: {new Date(item.expiresAt).toLocaleDateString()}
157
+ </Text>
158
+ )}
159
+
160
+ {/* Actions */}
161
+ {(showResend || showCancel) && (
162
+ <View className='flex-row items-center mt-2 pt-2 border-t border-gray-100 dark:border-gray-700'>
163
+ {showResend && (
164
+ <Pressable
165
+ onPress={() => onResend(item)}
166
+ className='flex-row items-center mr-4 py-1 active:opacity-60'
167
+ accessibilityRole='button'
168
+ accessibilityLabel='Resend invitation'
169
+ >
170
+ <Text className='text-blue-500 dark:text-blue-400 text-sm font-medium'>
171
+ Resend
172
+ </Text>
173
+ </Pressable>
174
+ )}
175
+ {showCancel && (
176
+ <Pressable
177
+ onPress={() => onCancel(item)}
178
+ className='flex-row items-center py-1 active:opacity-60'
179
+ accessibilityRole='button'
180
+ accessibilityLabel='Cancel invitation'
181
+ >
182
+ <Text className='text-red-500 dark:text-red-400 text-sm font-medium'>
183
+ Cancel
184
+ </Text>
185
+ </Pressable>
186
+ )}
187
+ </View>
188
+ )}
189
+ </View>
190
+ );
191
+ };
192
+
193
+ const renderEmpty = () => {
194
+ if (loading) {
195
+ return (
196
+ <View className='flex-1 items-center justify-center py-12'>
197
+ <ActivityIndicator size='large' color='#3B82F6' />
198
+ <Text className='text-gray-500 dark:text-gray-400 mt-4'>
199
+ Loading...
200
+ </Text>
201
+ </View>
202
+ );
203
+ }
204
+
205
+ return (
206
+ <View className='flex-1 items-center justify-center py-12'>
207
+ {emptyIcon}
208
+ <Text className='text-gray-500 dark:text-gray-400 text-center mt-2'>
209
+ {emptyMessage}
210
+ </Text>
211
+ </View>
212
+ );
213
+ };
214
+
215
+ return (
216
+ <View className={`flex-1 ${className}`} style={style} testID={testID}>
217
+ <FlatList
218
+ data={invitations}
219
+ renderItem={renderItem}
220
+ keyExtractor={item => item.id}
221
+ ListHeaderComponent={ListHeaderComponent}
222
+ ListFooterComponent={ListFooterComponent}
223
+ ListEmptyComponent={renderEmpty}
224
+ contentContainerStyle={{ flexGrow: 1, padding: 16 }}
225
+ showsVerticalScrollIndicator={false}
226
+ refreshControl={
227
+ onRefresh ? (
228
+ <RefreshControl
229
+ refreshing={refreshing}
230
+ onRefresh={onRefresh}
231
+ tintColor='#3B82F6'
232
+ />
233
+ ) : undefined
234
+ }
235
+ />
236
+ </View>
237
+ );
238
+ };
239
+
240
+ export default InvitationList;