Skip to content

Commit 480972b

Browse files
committed
feat: allow email invites in team event assignment (#13532)
- Add CreatableSelect support to CheckedTeamSelect for free-form email input - Implement email validation using emailSchema - Support bulk email paste (comma, semicolon, newline, space separated) - Integrate with existing inviteMember TRPC mutation - Add invitation status feedback via toast notifications - Enable email invites in EventTeamAssignmentTab for fixed and round-robin hosts - Add i18n translations for invitation feedback messages
1 parent 77b2be1 commit 480972b

4 files changed

Lines changed: 231 additions & 25 deletions

File tree

apps/web/modules/event-types/components/AddMembersWithSwitch.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import { Segment } from "./Segment";
1010
import { useLocale } from "@calcom/lib/hooks/useLocale";
1111
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
1212
import { Label, SettingsToggle } from "@calcom/ui/components/form";
13-
import { type ComponentProps, type Dispatch, type SetStateAction, useMemo } from "react";
13+
import { type ComponentProps, type Dispatch, type SetStateAction, useMemo, useCallback } from "react";
1414
import { Controller, useFormContext } from "react-hook-form";
1515
import type { Options } from "react-select";
1616
import { AddMembersWithSwitchWebWrapper } from "./AddMembersWithSwitchWebWrapper";
17+
import { trpc } from "@calcom/trpc/react";
18+
import { MembershipRole } from "@calcom/prisma/enums";
19+
import { CreationSource } from "@calcom/prisma/enums";
1720

1821
import AssignAllTeamMembers from "@calcom/features/eventtypes/components/AssignAllTeamMembers";
1922
import type {
@@ -63,6 +66,8 @@ const CheckedHostField = ({
6366
isRRWeightsEnabled,
6467
groupId,
6568
customClassNames,
69+
teamId,
70+
allowEmailInvites,
6671
...rest
6772
}: {
6873
labelText?: string;
@@ -74,7 +79,43 @@ const CheckedHostField = ({
7479
helperText?: React.ReactNode | string;
7580
isRRWeightsEnabled?: boolean;
7681
groupId: string | null;
82+
teamId?: number;
83+
allowEmailInvites?: boolean;
7784
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
85+
const { t } = useLocale();
86+
const inviteMutation = trpc.viewer.teams.inviteMember.useMutation();
87+
88+
// Handle inviting new members by email
89+
const handleEmailInvite = useCallback(
90+
async (emails: string[]): Promise<{ success: string[]; failed: string[] }> => {
91+
if (!teamId || emails.length === 0) {
92+
return { success: [], failed: emails };
93+
}
94+
95+
const success: string[] = [];
96+
const failed: string[] = [];
97+
98+
// Invite each email
99+
for (const email of emails) {
100+
try {
101+
await inviteMutation.mutateAsync({
102+
teamId,
103+
usernameOrEmail: email,
104+
role: MembershipRole.MEMBER,
105+
language: "en",
106+
creationSource: CreationSource.WEBAPP,
107+
});
108+
success.push(email);
109+
} catch (error) {
110+
failed.push(email);
111+
}
112+
}
113+
114+
return { success, failed };
115+
},
116+
[teamId, inviteMutation]
117+
);
118+
78119
return (
79120
<div className="flex flex-col rounded-md">
80121
<div>
@@ -116,6 +157,10 @@ const CheckedHostField = ({
116157
isRRWeightsEnabled={isRRWeightsEnabled}
117158
customClassNames={customClassNames}
118159
groupId={groupId}
160+
allowEmailInvites={allowEmailInvites}
161+
teamId={teamId}
162+
onEmailInvite={allowEmailInvites ? handleEmailInvite : undefined}
163+
isInviting={inviteMutation.isPending}
119164
{...rest}
120165
/>
121166
</div>
@@ -194,6 +239,8 @@ export type AddMembersWithSwitchProps = {
194239
groupId: string | null;
195240
"data-testid"?: string;
196241
customClassNames?: AddMembersWithSwitchCustomClassNames;
242+
// New prop for email invitation feature
243+
allowEmailInvites?: boolean;
197244
};
198245

199246
enum AssignmentState {
@@ -260,6 +307,7 @@ export function AddMembersWithSwitch({
260307
isSegmentApplicable,
261308
groupId,
262309
customClassNames,
310+
allowEmailInvites = false,
263311
...rest
264312
}: AddMembersWithSwitchProps) {
265313
const { t } = useLocale();
@@ -345,6 +393,8 @@ export function AddMembersWithSwitch({
345393
isRRWeightsEnabled={isRRWeightsEnabled}
346394
groupId={groupId}
347395
customClassNames={customClassNames?.teamMemberSelect}
396+
teamId={teamId}
397+
allowEmailInvites={allowEmailInvites}
348398
/>
349399
</div>
350400
</>

apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ const FixedHosts = ({
229229
isFixed={true}
230230
customClassNames={customClassNames?.addMembers}
231231
onActive={handleFixedHostsActivation}
232+
allowEmailInvites={true}
232233
/>
233234
</div>
234235
</>
@@ -260,6 +261,7 @@ const FixedHosts = ({
260261
automaticAddAllEnabled={!isRoundRobinEvent}
261262
isFixed={true}
262263
onActive={handleFixedHostsActivation}
264+
allowEmailInvites={true}
263265
/>
264266
</div>
265267
</SettingsToggle>
@@ -435,6 +437,7 @@ const RoundRobinHosts = ({
435437
containerClassName={containerClassName || (assignAllTeamMembers ? "-mt-4" : "")}
436438
onActive={() => handleMembersActivation(groupId)}
437439
customClassNames={customClassNames?.addMembers}
440+
allowEmailInvites={true}
438441
/>
439442
);
440443
};

packages/features/eventtypes/components/CheckedTeamSelect.tsx

Lines changed: 169 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
"use client";
22

33
import { useAutoAnimate } from "@formkit/auto-animate/react";
4-
import { useState } from "react";
4+
import { useState, useCallback } from "react";
55
import type { Options, Props } from "react-select";
6+
import CreatableSelect from "react-select/creatable";
67

78
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
89
import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types";
910
import { getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils";
1011
import { useLocale } from "@calcom/lib/hooks/useLocale";
12+
import { emailSchema } from "@calcom/lib/emailSchema";
1113
import classNames from "@calcom/ui/classNames";
1214
import { Avatar } from "@calcom/ui/components/avatar";
1315
import { Button } from "@calcom/ui/components/button";
1416
import { Select } from "@calcom/ui/components/form";
1517
import { Icon } from "@calcom/ui/components/icon";
1618
import { Tooltip } from "@calcom/ui/components/tooltip";
19+
import { showToast } from "@calcom/ui/components/toast";
1720

1821
import 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+
5283
export 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

packages/i18n/locales/en/common.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4774,5 +4774,13 @@
47744774
"dashboard": "dashboard",
47754775
"paypal_webhook_reminder": "Our integration creates a specific webhook on your PayPal account that we use to report back transactions to our system. If you delete this webhook, we will not be able to report back and you should uninstall and install the app again for this to work properly. Uninstalling the app won't delete your current event type price/currency configuration but you will not be able to receive bookings.",
47764776
"discount_25": "-25%",
4777+
"invalid_email_format": "Invalid email format",
4778+
"email_already_in_team": "Email is already in the team",
4779+
"invited_n_members_one": "Successfully invited {{count}} member",
4780+
"invited_n_members_other": "Successfully invited {{count}} members",
4781+
"failed_to_invite_n_members_one": "Failed to invite {{count}} member",
4782+
"failed_to_invite_n_members_other": "Failed to invite {{count}} members",
4783+
"invitation_failed": "Invitation failed",
4784+
"invite_email_address": "Type or paste email addresses to invite",
47774785
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
47784786
}

0 commit comments

Comments
 (0)