@linktr.ee/messaging-react 1.0.2 → 1.1.0-rc-1760927977
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/assets/index.css +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +836 -1079
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/components/ActionButton/ActionButton.stories.tsx +2 -1
- package/src/components/ActionButton/ActionButton.test.tsx +2 -0
- package/src/components/Avatar/Avatar.stories.tsx +2 -1
- package/src/components/Avatar/index.tsx +2 -1
- package/src/components/ChannelList/ChannelList.stories.tsx +4 -2
- package/src/components/ChannelList/CustomChannelPreview.stories.tsx +31 -27
- package/src/components/ChannelList/CustomChannelPreview.tsx +5 -11
- package/src/components/ChannelList/index.tsx +43 -35
- package/src/components/ChannelView.tsx +150 -127
- package/src/components/CloseButton/index.tsx +4 -5
- package/src/components/IconButton/IconButton.stories.tsx +3 -3
- package/src/components/Loading/Loading.stories.tsx +2 -1
- package/src/components/Loading/index.tsx +7 -9
- package/src/components/MessagingShell/EmptyState.stories.tsx +2 -1
- package/src/components/MessagingShell/ErrorState.stories.tsx +2 -1
- package/src/components/MessagingShell/LoadingState.stories.tsx +2 -1
- package/src/components/MessagingShell/LoadingState.tsx +3 -5
- package/src/components/MessagingShell/index.tsx +159 -135
- package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +4 -2
- package/src/components/ParticipantPicker/ParticipantItem.tsx +25 -21
- package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +4 -2
- package/src/components/ParticipantPicker/ParticipantPicker.tsx +104 -76
- package/src/components/ParticipantPicker/index.tsx +93 -72
- package/src/components/SearchInput/SearchInput.stories.tsx +2 -1
- package/src/components/SearchInput/SearchInput.test.tsx +4 -2
- package/src/components/SearchInput/index.tsx +14 -15
- package/src/hooks/useParticipants.ts +1 -0
- package/src/index.ts +3 -0
- package/src/providers/MessagingProvider.tsx +213 -135
- package/src/stories/mocks.tsx +18 -19
- package/src/styles.css +75 -0
- package/src/test/setup.ts +11 -12
- package/src/test/utils.tsx +6 -7
- package/src/types.ts +1 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { ChatCircleDotsIcon } from '@phosphor-icons/react'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import React, { useCallback, useEffect, useState, useRef } from 'react'
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import Loading from '../Loading'
|
|
9
|
-
import {
|
|
10
|
-
|
|
5
|
+
import { useMessagingContext } from '../../providers/MessagingProvider'
|
|
6
|
+
import type { ParticipantPickerProps, Participant } from '../../types'
|
|
7
|
+
import { CloseButton } from '../CloseButton'
|
|
8
|
+
import Loading from '../Loading'
|
|
9
|
+
import { SearchInput } from '../SearchInput'
|
|
10
|
+
|
|
11
|
+
import { ParticipantItem } from './ParticipantItem'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Generic participant picker component for starting conversations
|
|
@@ -21,91 +22,105 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
21
22
|
searchPlaceholder = 'Search participants...',
|
|
22
23
|
className,
|
|
23
24
|
}) => {
|
|
24
|
-
const { debug } = useMessagingContext()
|
|
25
|
-
const [searchQuery, setSearchQuery] = useState('')
|
|
26
|
-
const [participants, setParticipants] = useState<Participant[]>([])
|
|
27
|
-
const [loading, setLoading] = useState(false)
|
|
28
|
-
const [error, setError] = useState<string | null>(null)
|
|
29
|
-
const [startingChatWithId, setStartingChatWithId] = useState<string | null>(
|
|
25
|
+
const { debug } = useMessagingContext()
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
27
|
+
const [participants, setParticipants] = useState<Participant[]>([])
|
|
28
|
+
const [loading, setLoading] = useState(false)
|
|
29
|
+
const [error, setError] = useState<string | null>(null)
|
|
30
|
+
const [startingChatWithId, setStartingChatWithId] = useState<string | null>(
|
|
31
|
+
null
|
|
32
|
+
)
|
|
30
33
|
|
|
31
34
|
// Track if we've already loaded participants to prevent repeated loading
|
|
32
|
-
const loadedRef = useRef(false)
|
|
33
|
-
|
|
35
|
+
const loadedRef = useRef(false)
|
|
36
|
+
|
|
34
37
|
// Load participants initially - wait for participantSource to finish loading first
|
|
35
38
|
useEffect(() => {
|
|
36
39
|
// Wait for the participantSource to finish loading before we try to load participants
|
|
37
40
|
if (participantSource.loading) {
|
|
38
41
|
if (debug) {
|
|
39
|
-
console.log(
|
|
42
|
+
console.log(
|
|
43
|
+
'[ParticipantPicker] Waiting for participant source to finish loading...'
|
|
44
|
+
)
|
|
40
45
|
}
|
|
41
|
-
return
|
|
46
|
+
return
|
|
42
47
|
}
|
|
43
|
-
|
|
44
|
-
if (loadedRef.current) return
|
|
45
|
-
|
|
48
|
+
|
|
49
|
+
if (loadedRef.current) return // Prevent multiple loads
|
|
50
|
+
|
|
46
51
|
const loadInitialParticipants = async () => {
|
|
47
52
|
if (debug) {
|
|
48
|
-
console.log('[ParticipantPicker] Loading initial participants...')
|
|
53
|
+
console.log('[ParticipantPicker] Loading initial participants...')
|
|
49
54
|
}
|
|
50
|
-
setLoading(true)
|
|
51
|
-
setError(null)
|
|
52
|
-
|
|
55
|
+
setLoading(true)
|
|
56
|
+
setError(null)
|
|
57
|
+
|
|
53
58
|
try {
|
|
54
|
-
const result = await participantSource.loadParticipants({
|
|
59
|
+
const result = await participantSource.loadParticipants({
|
|
55
60
|
search: '', // Load all participants initially
|
|
56
|
-
limit: 100
|
|
57
|
-
})
|
|
58
|
-
setParticipants(result.participants)
|
|
59
|
-
loadedRef.current = true
|
|
61
|
+
limit: 100,
|
|
62
|
+
})
|
|
63
|
+
setParticipants(result.participants)
|
|
64
|
+
loadedRef.current = true // Mark as loaded
|
|
60
65
|
if (debug) {
|
|
61
|
-
console.log(
|
|
66
|
+
console.log(
|
|
67
|
+
'[ParticipantPicker] Participants loaded successfully:',
|
|
68
|
+
result.participants.length
|
|
69
|
+
)
|
|
62
70
|
}
|
|
63
71
|
} catch (err) {
|
|
64
|
-
const errorMessage =
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
const errorMessage =
|
|
73
|
+
err instanceof Error ? err.message : 'Failed to load participants'
|
|
74
|
+
setError(errorMessage)
|
|
75
|
+
console.error('[ParticipantPicker] Failed to load participants:', err)
|
|
67
76
|
// Don't mark as loaded on error, allow retry
|
|
68
77
|
} finally {
|
|
69
|
-
setLoading(false)
|
|
78
|
+
setLoading(false)
|
|
70
79
|
}
|
|
71
|
-
}
|
|
80
|
+
}
|
|
72
81
|
|
|
73
|
-
loadInitialParticipants()
|
|
74
|
-
}, [participantSource
|
|
82
|
+
loadInitialParticipants()
|
|
83
|
+
}, [participantSource, debug]) // Re-run when participantSource or debug changes
|
|
75
84
|
|
|
76
85
|
// Filter participants by search query and existing participants
|
|
77
86
|
const availableParticipants = participants
|
|
78
|
-
.filter(participant => !existingParticipantIds.has(participant.id))
|
|
79
|
-
.filter(participant => {
|
|
80
|
-
if (!searchQuery) return true
|
|
81
|
-
const searchLower = searchQuery.toLowerCase()
|
|
87
|
+
.filter((participant) => !existingParticipantIds.has(participant.id))
|
|
88
|
+
.filter((participant) => {
|
|
89
|
+
if (!searchQuery) return true
|
|
90
|
+
const searchLower = searchQuery.toLowerCase()
|
|
82
91
|
return (
|
|
83
92
|
participant.name.toLowerCase().includes(searchLower) ||
|
|
84
93
|
participant.email?.toLowerCase().includes(searchLower) ||
|
|
85
94
|
false
|
|
86
|
-
)
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
const handleSelectParticipant = useCallback(async (participant: Participant) => {
|
|
90
|
-
if (startingChatWithId) return; // Prevent multiple clicks
|
|
91
|
-
|
|
92
|
-
setStartingChatWithId(participant.id);
|
|
93
|
-
try {
|
|
94
|
-
await onSelectParticipant(participant);
|
|
95
|
-
} catch (error) {
|
|
96
|
-
console.error('[ParticipantPicker] Failed to start chat:', error);
|
|
97
|
-
// Reset the loading state on error
|
|
98
|
-
setStartingChatWithId(null);
|
|
99
|
-
}
|
|
100
|
-
// Note: Don't reset startingChatWithId on success because the dialog will close
|
|
101
|
-
}, [onSelectParticipant, startingChatWithId]);
|
|
95
|
+
)
|
|
96
|
+
})
|
|
102
97
|
|
|
103
|
-
const
|
|
98
|
+
const handleSelectParticipant = useCallback(
|
|
99
|
+
async (participant: Participant) => {
|
|
100
|
+
if (startingChatWithId) return // Prevent multiple clicks
|
|
101
|
+
|
|
102
|
+
setStartingChatWithId(participant.id)
|
|
103
|
+
try {
|
|
104
|
+
await onSelectParticipant(participant)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('[ParticipantPicker] Failed to start chat:', error)
|
|
107
|
+
// Reset the loading state on error
|
|
108
|
+
setStartingChatWithId(null)
|
|
109
|
+
}
|
|
110
|
+
// Note: Don't reset startingChatWithId on success because the dialog will close
|
|
111
|
+
},
|
|
112
|
+
[onSelectParticipant, startingChatWithId]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const handleKeyDown = (
|
|
116
|
+
event: React.KeyboardEvent,
|
|
117
|
+
participant: Participant
|
|
118
|
+
) => {
|
|
104
119
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
105
|
-
event.preventDefault()
|
|
106
|
-
handleSelectParticipant(participant)
|
|
120
|
+
event.preventDefault()
|
|
121
|
+
handleSelectParticipant(participant)
|
|
107
122
|
}
|
|
108
|
-
}
|
|
123
|
+
}
|
|
109
124
|
|
|
110
125
|
return (
|
|
111
126
|
<div className={classNames('flex flex-col h-full', className)}>
|
|
@@ -117,10 +132,12 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
117
132
|
</h2>
|
|
118
133
|
<CloseButton onClick={onClose} />
|
|
119
134
|
</div>
|
|
120
|
-
|
|
135
|
+
|
|
121
136
|
<p className="text-xs text-stone mb-3">
|
|
122
|
-
Select a {participantLabel.slice(0, -1)} to start messaging (
|
|
123
|
-
{
|
|
137
|
+
Select a {participantLabel.slice(0, -1)} to start messaging (
|
|
138
|
+
{availableParticipants.length} available)
|
|
139
|
+
{participantSource.totalCount !== undefined &&
|
|
140
|
+
` • ${participantSource.totalCount} ${participantLabel} total`}
|
|
124
141
|
</p>
|
|
125
142
|
|
|
126
143
|
<SearchInput
|
|
@@ -143,7 +160,9 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
143
160
|
<div className="h-32 flex items-center justify-center">
|
|
144
161
|
<div className="flex items-center space-x-2">
|
|
145
162
|
<div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
|
146
|
-
<span className="text-sm text-stone">
|
|
163
|
+
<span className="text-sm text-stone">
|
|
164
|
+
Loading {participantLabel}...
|
|
165
|
+
</span>
|
|
147
166
|
</div>
|
|
148
167
|
</div>
|
|
149
168
|
) : availableParticipants.length === 0 ? (
|
|
@@ -169,19 +188,30 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
169
188
|
) : (
|
|
170
189
|
<ul className="space-y-0">
|
|
171
190
|
{availableParticipants.map((participant) => {
|
|
172
|
-
const displayName =
|
|
173
|
-
|
|
191
|
+
const displayName =
|
|
192
|
+
participant.name || participant.email || participant.id
|
|
193
|
+
const displaySecondary =
|
|
194
|
+
participant.email && participant.name
|
|
195
|
+
? participant.email
|
|
196
|
+
: participant.phone
|
|
174
197
|
|
|
175
198
|
return (
|
|
176
|
-
<ParticipantItem
|
|
177
|
-
|
|
199
|
+
<ParticipantItem
|
|
200
|
+
key={participant.id}
|
|
201
|
+
participant={participant}
|
|
202
|
+
handleSelectParticipant={handleSelectParticipant}
|
|
203
|
+
handleKeyDown={handleKeyDown}
|
|
204
|
+
displayName={displayName}
|
|
205
|
+
displaySecondary={displaySecondary}
|
|
206
|
+
/>
|
|
207
|
+
)
|
|
178
208
|
})}
|
|
179
209
|
|
|
180
210
|
{/* Loading indicator */}
|
|
181
211
|
{loading && (
|
|
182
212
|
<li className="p-4 flex justify-center">
|
|
183
213
|
<div className="flex items-center space-x-2">
|
|
184
|
-
<Loading className=
|
|
214
|
+
<Loading className="w-6 h-6" />
|
|
185
215
|
<span className="text-sm text-stone">Loading more...</span>
|
|
186
216
|
</div>
|
|
187
217
|
</li>
|
|
@@ -190,7 +220,5 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
190
220
|
)}
|
|
191
221
|
</div>
|
|
192
222
|
</div>
|
|
193
|
-
)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
223
|
+
)
|
|
224
|
+
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import classNames from 'classnames';
|
|
1
|
+
import { ChatCircleDotsIcon, SpinnerGapIcon } from '@phosphor-icons/react'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import React, { useCallback, useEffect, useState, useRef } from 'react'
|
|
5
4
|
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
5
|
+
import { useMessagingContext } from '../../providers/MessagingProvider'
|
|
6
|
+
import type { ParticipantPickerProps, Participant } from '../../types'
|
|
7
|
+
import { Avatar } from '../Avatar'
|
|
8
|
+
import { CloseButton } from '../CloseButton'
|
|
9
|
+
import { SearchInput } from '../SearchInput'
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* Generic participant picker component for starting conversations
|
|
@@ -21,91 +20,105 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
21
20
|
searchPlaceholder = 'Search participants...',
|
|
22
21
|
className,
|
|
23
22
|
}) => {
|
|
24
|
-
const { debug } = useMessagingContext()
|
|
25
|
-
const [searchQuery, setSearchQuery] = useState('')
|
|
26
|
-
const [participants, setParticipants] = useState<Participant[]>([])
|
|
27
|
-
const [loading, setLoading] = useState(false)
|
|
28
|
-
const [error, setError] = useState<string | null>(null)
|
|
29
|
-
const [startingChatWithId, setStartingChatWithId] = useState<string | null>(
|
|
23
|
+
const { debug } = useMessagingContext()
|
|
24
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
25
|
+
const [participants, setParticipants] = useState<Participant[]>([])
|
|
26
|
+
const [loading, setLoading] = useState(false)
|
|
27
|
+
const [error, setError] = useState<string | null>(null)
|
|
28
|
+
const [startingChatWithId, setStartingChatWithId] = useState<string | null>(
|
|
29
|
+
null
|
|
30
|
+
)
|
|
30
31
|
|
|
31
32
|
// Track if we've already loaded participants to prevent repeated loading
|
|
32
|
-
const loadedRef = useRef(false)
|
|
33
|
-
|
|
33
|
+
const loadedRef = useRef(false)
|
|
34
|
+
|
|
34
35
|
// Load participants initially - wait for participantSource to finish loading first
|
|
35
36
|
useEffect(() => {
|
|
36
37
|
// Wait for the participantSource to finish loading before we try to load participants
|
|
37
38
|
if (participantSource.loading) {
|
|
38
39
|
if (debug) {
|
|
39
|
-
console.log(
|
|
40
|
+
console.log(
|
|
41
|
+
'[ParticipantPicker] Waiting for participant source to finish loading...'
|
|
42
|
+
)
|
|
40
43
|
}
|
|
41
|
-
return
|
|
44
|
+
return
|
|
42
45
|
}
|
|
43
|
-
|
|
44
|
-
if (loadedRef.current) return
|
|
45
|
-
|
|
46
|
+
|
|
47
|
+
if (loadedRef.current) return // Prevent multiple loads
|
|
48
|
+
|
|
46
49
|
const loadInitialParticipants = async () => {
|
|
47
50
|
if (debug) {
|
|
48
|
-
console.log('[ParticipantPicker] Loading initial participants...')
|
|
51
|
+
console.log('[ParticipantPicker] Loading initial participants...')
|
|
49
52
|
}
|
|
50
|
-
setLoading(true)
|
|
51
|
-
setError(null)
|
|
52
|
-
|
|
53
|
+
setLoading(true)
|
|
54
|
+
setError(null)
|
|
55
|
+
|
|
53
56
|
try {
|
|
54
|
-
const result = await participantSource.loadParticipants({
|
|
57
|
+
const result = await participantSource.loadParticipants({
|
|
55
58
|
search: '', // Load all participants initially
|
|
56
|
-
limit: 100
|
|
57
|
-
})
|
|
58
|
-
setParticipants(result.participants)
|
|
59
|
-
loadedRef.current = true
|
|
59
|
+
limit: 100,
|
|
60
|
+
})
|
|
61
|
+
setParticipants(result.participants)
|
|
62
|
+
loadedRef.current = true // Mark as loaded
|
|
60
63
|
if (debug) {
|
|
61
|
-
console.log(
|
|
64
|
+
console.log(
|
|
65
|
+
'[ParticipantPicker] Participants loaded successfully:',
|
|
66
|
+
result.participants.length
|
|
67
|
+
)
|
|
62
68
|
}
|
|
63
69
|
} catch (err) {
|
|
64
|
-
const errorMessage =
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
const errorMessage =
|
|
71
|
+
err instanceof Error ? err.message : 'Failed to load participants'
|
|
72
|
+
setError(errorMessage)
|
|
73
|
+
console.error('[ParticipantPicker] Failed to load participants:', err)
|
|
67
74
|
// Don't mark as loaded on error, allow retry
|
|
68
75
|
} finally {
|
|
69
|
-
setLoading(false)
|
|
76
|
+
setLoading(false)
|
|
70
77
|
}
|
|
71
|
-
}
|
|
78
|
+
}
|
|
72
79
|
|
|
73
|
-
loadInitialParticipants()
|
|
74
|
-
}, [participantSource.loading, debug])
|
|
80
|
+
loadInitialParticipants()
|
|
81
|
+
}, [participantSource.loading, debug]) // Re-run when loading state changes
|
|
75
82
|
|
|
76
83
|
// Filter participants by search query and existing participants
|
|
77
84
|
const availableParticipants = participants
|
|
78
|
-
.filter(participant => !existingParticipantIds.has(participant.id))
|
|
79
|
-
.filter(participant => {
|
|
80
|
-
if (!searchQuery) return true
|
|
81
|
-
const searchLower = searchQuery.toLowerCase()
|
|
85
|
+
.filter((participant) => !existingParticipantIds.has(participant.id))
|
|
86
|
+
.filter((participant) => {
|
|
87
|
+
if (!searchQuery) return true
|
|
88
|
+
const searchLower = searchQuery.toLowerCase()
|
|
82
89
|
return (
|
|
83
90
|
participant.name.toLowerCase().includes(searchLower) ||
|
|
84
91
|
participant.email?.toLowerCase().includes(searchLower) ||
|
|
85
92
|
false
|
|
86
|
-
)
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
const handleSelectParticipant = useCallback(async (participant: Participant) => {
|
|
90
|
-
if (startingChatWithId) return; // Prevent multiple clicks
|
|
91
|
-
|
|
92
|
-
setStartingChatWithId(participant.id);
|
|
93
|
-
try {
|
|
94
|
-
await onSelectParticipant(participant);
|
|
95
|
-
} catch (error) {
|
|
96
|
-
console.error('[ParticipantPicker] Failed to start chat:', error);
|
|
97
|
-
// Reset the loading state on error
|
|
98
|
-
setStartingChatWithId(null);
|
|
99
|
-
}
|
|
100
|
-
// Note: Don't reset startingChatWithId on success because the dialog will close
|
|
101
|
-
}, [onSelectParticipant, startingChatWithId]);
|
|
93
|
+
)
|
|
94
|
+
})
|
|
102
95
|
|
|
103
|
-
const
|
|
96
|
+
const handleSelectParticipant = useCallback(
|
|
97
|
+
async (participant: Participant) => {
|
|
98
|
+
if (startingChatWithId) return // Prevent multiple clicks
|
|
99
|
+
|
|
100
|
+
setStartingChatWithId(participant.id)
|
|
101
|
+
try {
|
|
102
|
+
await onSelectParticipant(participant)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('[ParticipantPicker] Failed to start chat:', error)
|
|
105
|
+
// Reset the loading state on error
|
|
106
|
+
setStartingChatWithId(null)
|
|
107
|
+
}
|
|
108
|
+
// Note: Don't reset startingChatWithId on success because the dialog will close
|
|
109
|
+
},
|
|
110
|
+
[onSelectParticipant, startingChatWithId]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const handleKeyDown = (
|
|
114
|
+
event: React.KeyboardEvent,
|
|
115
|
+
participant: Participant
|
|
116
|
+
) => {
|
|
104
117
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
105
|
-
event.preventDefault()
|
|
106
|
-
handleSelectParticipant(participant)
|
|
118
|
+
event.preventDefault()
|
|
119
|
+
handleSelectParticipant(participant)
|
|
107
120
|
}
|
|
108
|
-
}
|
|
121
|
+
}
|
|
109
122
|
|
|
110
123
|
return (
|
|
111
124
|
<div className={classNames('flex flex-col h-full', className)}>
|
|
@@ -117,10 +130,12 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
117
130
|
</h2>
|
|
118
131
|
<CloseButton onClick={onClose} />
|
|
119
132
|
</div>
|
|
120
|
-
|
|
133
|
+
|
|
121
134
|
<p className="text-xs text-stone mb-3">
|
|
122
|
-
Select a {participantLabel.slice(0, -1)} to start messaging (
|
|
123
|
-
{
|
|
135
|
+
Select a {participantLabel.slice(0, -1)} to start messaging (
|
|
136
|
+
{availableParticipants.length} available)
|
|
137
|
+
{participantSource.totalCount !== undefined &&
|
|
138
|
+
` • ${participantSource.totalCount} ${participantLabel} total`}
|
|
124
139
|
</p>
|
|
125
140
|
|
|
126
141
|
<SearchInput
|
|
@@ -143,7 +158,9 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
143
158
|
<div className="h-32 flex items-center justify-center">
|
|
144
159
|
<div className="flex items-center space-x-2">
|
|
145
160
|
<div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
|
146
|
-
<span className="text-sm text-stone">
|
|
161
|
+
<span className="text-sm text-stone">
|
|
162
|
+
Loading {participantLabel}...
|
|
163
|
+
</span>
|
|
147
164
|
</div>
|
|
148
165
|
</div>
|
|
149
166
|
) : availableParticipants.length === 0 ? (
|
|
@@ -169,8 +186,12 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
169
186
|
) : (
|
|
170
187
|
<ul className="space-y-0">
|
|
171
188
|
{availableParticipants.map((participant) => {
|
|
172
|
-
const displayName =
|
|
173
|
-
|
|
189
|
+
const displayName =
|
|
190
|
+
participant.name || participant.email || participant.id
|
|
191
|
+
const displaySecondary =
|
|
192
|
+
participant.email && participant.name
|
|
193
|
+
? participant.email
|
|
194
|
+
: participant.phone
|
|
174
195
|
|
|
175
196
|
return (
|
|
176
197
|
<li key={participant.id}>
|
|
@@ -214,7 +235,7 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
214
235
|
</div>
|
|
215
236
|
</button>
|
|
216
237
|
</li>
|
|
217
|
-
)
|
|
238
|
+
)
|
|
218
239
|
})}
|
|
219
240
|
|
|
220
241
|
{/* Loading indicator */}
|
|
@@ -230,5 +251,5 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
|
230
251
|
)}
|
|
231
252
|
</div>
|
|
232
253
|
</div>
|
|
233
|
-
)
|
|
234
|
-
}
|
|
254
|
+
)
|
|
255
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
2
3
|
import { renderWithProviders, screen, userEvent } from '../../test/utils';
|
|
4
|
+
|
|
3
5
|
import { SearchInput } from './index';
|
|
4
6
|
|
|
5
7
|
describe('SearchInput', () => {
|
|
@@ -54,7 +56,7 @@ describe('SearchInput', () => {
|
|
|
54
56
|
<SearchInput searchQuery="test" setSearchQuery={vi.fn()} placeholder="Search" />
|
|
55
57
|
);
|
|
56
58
|
|
|
57
|
-
const clearButton = screen.
|
|
59
|
+
const clearButton = screen.getByRole('button', { name: /clear/i });
|
|
58
60
|
expect(clearButton).toBeInTheDocument();
|
|
59
61
|
});
|
|
60
62
|
|
|
@@ -75,7 +77,7 @@ describe('SearchInput', () => {
|
|
|
75
77
|
<SearchInput searchQuery="test" setSearchQuery={handleChange} placeholder="Search" />
|
|
76
78
|
);
|
|
77
79
|
|
|
78
|
-
const clearButton = screen.
|
|
80
|
+
const clearButton = screen.getByRole('button', { name: /clear/i });
|
|
79
81
|
await user.click(clearButton);
|
|
80
82
|
|
|
81
83
|
expect(handleChange).toHaveBeenCalledWith('');
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
import {
|
|
1
|
+
import { MagnifyingGlassIcon, XIcon } from '@phosphor-icons/react'
|
|
2
|
+
import { useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
import { IconButton } from '../IconButton'
|
|
5
5
|
|
|
6
6
|
interface SearchInputProps {
|
|
7
|
-
searchQuery: string
|
|
8
|
-
setSearchQuery: (value: string) => void
|
|
9
|
-
placeholder: string
|
|
7
|
+
searchQuery: string
|
|
8
|
+
setSearchQuery: (value: string) => void
|
|
9
|
+
placeholder: string
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function SearchInput({
|
|
@@ -14,7 +14,7 @@ export function SearchInput({
|
|
|
14
14
|
setSearchQuery,
|
|
15
15
|
placeholder,
|
|
16
16
|
}: SearchInputProps) {
|
|
17
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
17
|
+
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
18
18
|
|
|
19
19
|
return (
|
|
20
20
|
<div className="relative">
|
|
@@ -22,7 +22,7 @@ export function SearchInput({
|
|
|
22
22
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone"
|
|
23
23
|
weight="bold"
|
|
24
24
|
/>
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
<input
|
|
27
27
|
ref={searchInputRef}
|
|
28
28
|
type="text"
|
|
@@ -31,20 +31,19 @@ export function SearchInput({
|
|
|
31
31
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
32
32
|
className="w-full pl-10 pr-10 py-3 text-sm border border-sand rounded-xl focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
|
|
33
33
|
/>
|
|
34
|
-
|
|
34
|
+
|
|
35
35
|
{searchQuery && (
|
|
36
36
|
<IconButton
|
|
37
37
|
label="Clear search"
|
|
38
38
|
onClick={() => {
|
|
39
|
-
setSearchQuery('')
|
|
40
|
-
searchInputRef.current?.focus()
|
|
39
|
+
setSearchQuery('')
|
|
40
|
+
searchInputRef.current?.focus()
|
|
41
41
|
}}
|
|
42
42
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-stone hover:text-charcoal"
|
|
43
43
|
>
|
|
44
|
-
|
|
44
|
+
<XIcon className="h-4 w-4" weight="bold" />
|
|
45
45
|
</IconButton>
|
|
46
46
|
)}
|
|
47
47
|
</div>
|
|
48
|
-
)
|
|
48
|
+
)
|
|
49
49
|
}
|
|
50
|
-
|