11"use client" ;
22
33import { useAutoAnimate } from "@formkit/auto-animate/react" ;
4- import { useState } from "react" ;
4+ import { useState , useCallback } from "react" ;
55import type { Options , Props } from "react-select" ;
6+ import CreatableSelect from "react-select/creatable" ;
67
78import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform" ;
89import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types" ;
910import { getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils" ;
1011import { useLocale } from "@calcom/lib/hooks/useLocale" ;
12+ import { emailSchema } from "@calcom/lib/emailSchema" ;
1113import classNames from "@calcom/ui/classNames" ;
1214import { Avatar } from "@calcom/ui/components/avatar" ;
1315import { Button } from "@calcom/ui/components/button" ;
1416import { Select } from "@calcom/ui/components/form" ;
1517import { Icon } from "@calcom/ui/components/icon" ;
1618import { Tooltip } from "@calcom/ui/components/tooltip" ;
19+ import { showToast } from "@calcom/ui/components/toast" ;
1720
1821import type {
1922 PriorityDialogCustomClassNames ,
@@ -49,24 +52,51 @@ export type CheckedTeamSelectCustomClassNames = {
4952 priorityDialog ?: PriorityDialogCustomClassNames ;
5053 weightDialog ?: WeightDialogCustomClassNames ;
5154} ;
55+
56+ // Props for email invitation support
57+ export type CheckedTeamSelectProps = Omit < Props < CheckedSelectOption , true > , "value" | "onChange" > & {
58+ options ?: Options < CheckedSelectOption > ;
59+ value ?: readonly CheckedSelectOption [ ] ;
60+ onChange : ( value : readonly CheckedSelectOption [ ] ) => void ;
61+ isRRWeightsEnabled ?: boolean ;
62+ customClassNames ?: CheckedTeamSelectCustomClassNames ;
63+ groupId : string | null ;
64+ // New props for email invitation
65+ allowEmailInvites ?: boolean ;
66+ teamId ?: number ;
67+ onEmailInvite ?: ( emails : string [ ] ) => Promise < { success : string [ ] ; failed : string [ ] } > ;
68+ isInviting ?: boolean ;
69+ } ;
70+ // Email validation helper
71+ const isValidEmail = ( email : string ) : boolean => {
72+ return emailSchema . safeParse ( email ) . success ;
73+ } ;
74+
75+ // Parse pasted text for multiple emails
76+ const parsePastedEmails = ( text : string ) : string [ ] => {
77+ return text
78+ . split ( / [ , ; \n \r \s ] + / )
79+ . map ( ( email ) => email . trim ( ) . toLowerCase ( ) )
80+ . filter ( ( email ) => email . length > 0 && isValidEmail ( email ) ) ;
81+ } ;
82+
5283export const CheckedTeamSelect = ( {
5384 options = [ ] ,
5485 value = [ ] ,
5586 isRRWeightsEnabled,
5687 customClassNames,
5788 groupId,
89+ allowEmailInvites = false ,
90+ teamId,
91+ onEmailInvite,
92+ isInviting = false ,
5893 ...props
59- } : Omit < Props < CheckedSelectOption , true > , "value" | "onChange" > & {
60- options ?: Options < CheckedSelectOption > ;
61- value ?: readonly CheckedSelectOption [ ] ;
62- onChange : ( value : readonly CheckedSelectOption [ ] ) => void ;
63- isRRWeightsEnabled ?: boolean ;
64- customClassNames ?: CheckedTeamSelectCustomClassNames ;
65- groupId : string | null ;
66- } ) => {
94+ } : CheckedTeamSelectProps ) => {
6795 const isPlatform = useIsPlatform ( ) ;
6896 const [ priorityDialogOpen , setPriorityDialogOpen ] = useState ( false ) ;
6997 const [ weightDialogOpen , setWeightDialogOpen ] = useState ( false ) ;
98+ const [ inputValue , setInputValue ] = useState ( "" ) ;
99+ const [ isProcessingEmails , setIsProcessingEmails ] = useState ( false ) ;
70100
71101 const [ currentOption , setCurrentOption ] = useState ( value [ 0 ] ?? null ) ;
72102
@@ -82,23 +112,138 @@ export const CheckedTeamSelect = ({
82112 props . onChange ( newValueAllGroups ) ;
83113 } ;
84114
115+ // Handle creating new option from email input
116+ const handleCreateOption = useCallback (
117+ async ( inputValue : string ) => {
118+ if ( ! allowEmailInvites || ! onEmailInvite ) return ;
119+
120+ const emails = parsePastedEmails ( inputValue ) ;
121+ if ( emails . length === 0 ) {
122+ showToast ( t ( "invalid_email_format" ) , "error" ) ;
123+ return ;
124+ }
125+
126+ // Check which emails are already in options (by checking if label matches email)
127+ const existingEmails = new Set ( options . map ( ( opt ) => opt . label ?. toLowerCase ( ) ) . filter ( Boolean ) ) ;
128+ const newEmails = emails . filter ( ( email ) => ! existingEmails . has ( email ) ) ;
129+
130+ if ( newEmails . length === 0 ) {
131+ showToast ( t ( "emails_already_in_team" ) , "warning" ) ;
132+ return ;
133+ }
134+
135+ setIsProcessingEmails ( true ) ;
136+ try {
137+ const result = await onEmailInvite ( newEmails ) ;
138+
139+ if ( result . success . length > 0 ) {
140+ showToast (
141+ t ( "invited_n_members" , { count : result . success . length } ) ,
142+ "success"
143+ ) ;
144+ }
145+
146+ if ( result . failed . length > 0 ) {
147+ showToast (
148+ t ( "failed_to_invite_n_members" , { count : result . failed . length } ) ,
149+ "error"
150+ ) ;
151+ }
152+ } catch ( error ) {
153+ showToast ( t ( "invitation_failed" ) , "error" ) ;
154+ } finally {
155+ setIsProcessingEmails ( false ) ;
156+ setInputValue ( "" ) ;
157+ }
158+ } ,
159+ [ allowEmailInvites , onEmailInvite , options , t ]
160+ ) ;
161+
162+ // Handle paste event for bulk email input
163+ const handlePaste = useCallback (
164+ async ( event : React . ClipboardEvent < HTMLInputElement > ) => {
165+ if ( ! allowEmailInvites ) return ;
166+
167+ const pastedText = event . clipboardData . getData ( "text" ) ;
168+ const emails = parsePastedEmails ( pastedText ) ;
169+
170+ // If we have multiple emails or a valid single email that's not in options, process it
171+ if ( emails . length >= 1 ) {
172+ const existingEmails = new Set ( options . map ( ( opt ) => opt . label ?. toLowerCase ( ) ) . filter ( Boolean ) ) ;
173+ const newEmails = emails . filter ( ( email ) => ! existingEmails . has ( email ) ) ;
174+
175+ if ( newEmails . length > 0 && onEmailInvite ) {
176+ event . preventDefault ( ) ;
177+ setIsProcessingEmails ( true ) ;
178+ try {
179+ const result = await onEmailInvite ( newEmails ) ;
180+
181+ if ( result . success . length > 0 ) {
182+ showToast (
183+ t ( "invited_n_members" , { count : result . success . length } ) ,
184+ "success"
185+ ) ;
186+ }
187+
188+ if ( result . failed . length > 0 ) {
189+ showToast (
190+ t ( "failed_to_invite_n_members" , { count : result . failed . length } ) ,
191+ "error"
192+ ) ;
193+ }
194+ } catch ( error ) {
195+ showToast ( t ( "invitation_failed" ) , "error" ) ;
196+ } finally {
197+ setIsProcessingEmails ( false ) ;
198+ }
199+ }
200+ }
201+ } ,
202+ [ allowEmailInvites , onEmailInvite , options , t ]
203+ ) ;
204+
205+ // Common select props
206+ const commonSelectProps = {
207+ ...props ,
208+ name : props . name ,
209+ placeholder : props . placeholder || t ( "select" ) ,
210+ isSearchable : true ,
211+ options,
212+ value : valueFromGroup ,
213+ onChange : handleSelectChange ,
214+ isMulti : true ,
215+ isDisabled : isInviting || isProcessingEmails ,
216+ isLoading : isInviting || isProcessingEmails ,
217+ className : customClassNames ?. hostsSelect ?. select ,
218+ } ;
219+
85220 return (
86221 < >
87- < Select
88- { ...props }
89- name = { props . name }
90- placeholder = { props . placeholder || t ( "select" ) }
91- isSearchable = { true }
92- options = { options }
93- value = { valueFromGroup }
94- onChange = { handleSelectChange }
95- isMulti
96- className = { customClassNames ?. hostsSelect ?. select }
97- innerClassNames = { {
98- ...customClassNames ?. hostsSelect ?. innerClassNames ,
99- control : "rounded-md" ,
100- } }
101- />
222+ { allowEmailInvites ? (
223+ < CreatableSelect
224+ { ...commonSelectProps }
225+ inputValue = { inputValue }
226+ onInputChange = { ( newValue : string ) => setInputValue ( newValue ) }
227+ onCreateOption = { handleCreateOption }
228+ onPaste = { handlePaste }
229+ formatCreateLabel = { ( inputValue : string ) =>
230+ isValidEmail ( inputValue )
231+ ? t ( "invite_email_address" , { email : inputValue } )
232+ : t ( "enter_valid_email" )
233+ }
234+ isValidNewOption = { ( inputValue : string ) => isValidEmail ( inputValue ) }
235+ className = { customClassNames ?. hostsSelect ?. select }
236+ />
237+ ) : (
238+ < Select
239+ { ...commonSelectProps }
240+ className = { customClassNames ?. hostsSelect ?. select }
241+ innerClassNames = { {
242+ ...customClassNames ?. hostsSelect ?. innerClassNames ,
243+ control : "rounded-md" ,
244+ } }
245+ />
246+ ) }
102247 { /* This class name conditional looks a bit odd but it allows a seamless transition when using autoanimate
103248 - Slides down from the top instead of just teleporting in from nowhere*/ }
104249 < ul
0 commit comments