Skip to content

Commit f1253f7

Browse files
committed
feat(CAL-3076): allow emails and invite people to a team event-type directly from assignment
1 parent d6a17b2 commit f1253f7

File tree

6 files changed

+215
-160
lines changed

6 files changed

+215
-160
lines changed
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
22
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
33

4-
export default createNextApiHandler(eventTypesRouter);
4+
export default createNextApiHandler({
5+
router: eventTypesRouter,
6+
});

packages/features/eventtypes/components/CheckedTeamSelect.tsx

Lines changed: 96 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,35 @@
33
import { useAutoAnimate } from "@formkit/auto-animate/react";
44
import { useState } from "react";
55
import type { Props } from "react-select";
6+
import CreatableSelect from "react-select/creatable";
67

8+
// ✅ NEW
79
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
810
import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types";
911
import { useLocale } from "@calcom/lib/hooks/useLocale";
10-
import { Icon } from "@calcom/ui/components/icon";
11-
import { Select } from "@calcom/ui/components/form";
12-
import { Tooltip } from "@calcom/ui/components/tooltip";
12+
import classNames from "@calcom/ui/classNames";
1313
import { Avatar } from "@calcom/ui/components/avatar";
1414
import { Button } from "@calcom/ui/components/button";
15-
import classNames from "@calcom/ui/classNames";
15+
import { Icon } from "@calcom/ui/components/icon";
16+
// import { Select } from "@calcom/ui/components/form"; ❌ remove old Select
17+
import { Tooltip } from "@calcom/ui/components/tooltip";
1618

1719
import type { PriorityDialogCustomClassNames, WeightDialogCustomClassNames } from "./HostEditDialogs";
1820
import { PriorityDialog, WeightDialog } from "./HostEditDialogs";
1921

22+
// ✅ simple email regex util
23+
const isValidEmail = (val: string) => /\S+@\S+\.\S+/.test(val);
24+
2025
export type CheckedSelectOption = {
21-
avatar: string;
26+
avatar?: string;
2227
label: string;
2328
value: string;
2429
priority?: number;
2530
weight?: number;
2631
isFixed?: boolean;
2732
disabled?: boolean;
2833
defaultScheduleId?: number | null;
34+
isPending?: boolean; // ✅ NEW
2935
};
3036

3137
export type CheckedTeamSelectCustomClassNames = {
@@ -44,6 +50,7 @@ export type CheckedTeamSelectCustomClassNames = {
4450
priorityDialog?: PriorityDialogCustomClassNames;
4551
weightDialog?: WeightDialogCustomClassNames;
4652
};
53+
4754
export const CheckedTeamSelect = ({
4855
options = [],
4956
value = [],
@@ -67,19 +74,32 @@ export const CheckedTeamSelect = ({
6774

6875
return (
6976
<>
70-
<Select
77+
<CreatableSelect<CheckedSelectOption, true>
7178
{...props}
7279
name={props.name}
7380
placeholder={props.placeholder || t("select")}
74-
isSearchable={true}
81+
isMulti
82+
isSearchable
7583
options={options}
7684
value={value}
77-
isMulti
7885
className={customClassNames?.hostsSelect?.select}
79-
innerClassNames={customClassNames?.hostsSelect?.innerClassNames}
86+
classNames={customClassNames?.hostsSelect?.innerClassNames as any}
87+
onChange={(newVal) => props.onChange(newVal)}
88+
onCreateOption={(inputValue) => {
89+
if (isValidEmail(inputValue)) {
90+
const newOption: CheckedSelectOption = {
91+
value: inputValue,
92+
label: `${inputValue} (invite pending)`,
93+
avatar: "", // no avatar for pending
94+
isPending: true,
95+
};
96+
props.onChange([...(value || []), newOption]);
97+
} else {
98+
alert("Invalid email address");
99+
}
100+
}}
80101
/>
81-
{/* This class name conditional looks a bit odd but it allows a seamless transition when using autoanimate
82-
- Slides down from the top instead of just teleporting in from nowhere*/}
102+
83103
<ul
84104
className={classNames(
85105
"mb-4 mt-3 rounded-md",
@@ -88,83 +108,78 @@ export const CheckedTeamSelect = ({
88108
)}
89109
ref={animationRef}>
90110
{value.map((option, index) => (
91-
<>
92-
<li
93-
key={option.value}
111+
<li
112+
key={option.value}
113+
className={classNames(
114+
`flex px-3 py-2 ${index === value.length - 1 ? "" : "border-subtle border-b"}`,
115+
customClassNames?.selectedHostList?.listItem?.container
116+
)}>
117+
{!isPlatform && option.avatar && <Avatar size="sm" imageSrc={option.avatar} alt={option.label} />}
118+
{(!option.avatar || isPlatform) && (
119+
<Icon
120+
name="user"
121+
className={classNames("mt-0.5 h-4 w-4", customClassNames?.selectedHostList?.listItem?.avatar)}
122+
/>
123+
)}
124+
<p
94125
className={classNames(
95-
`flex px-3 py-2 ${index === value.length - 1 ? "" : "border-subtle border-b"}`,
96-
customClassNames?.selectedHostList?.listItem?.container
126+
"text-emphasis my-auto ms-3 text-sm",
127+
customClassNames?.selectedHostList?.listItem?.name
97128
)}>
98-
{!isPlatform && <Avatar size="sm" imageSrc={option.avatar} alt={option.label} />}
99-
{isPlatform && (
100-
<Icon
101-
name="user"
102-
className={classNames(
103-
"mt-0.5 h-4 w-4",
104-
customClassNames?.selectedHostList?.listItem?.avatar
105-
)}
106-
/>
129+
{option.label}
130+
</p>
131+
132+
<div className="ml-auto flex items-center">
133+
{/* Skip priority/weight for pending emails */}
134+
{!option.isPending && !option.isFixed && (
135+
<>
136+
<Tooltip content={t("change_priority")}>
137+
<Button
138+
color="minimal"
139+
onClick={() => {
140+
setPriorityDialogOpen(true);
141+
setCurrentOption(option);
142+
}}
143+
className={classNames(
144+
"mr-6 h-2 p-0 text-sm hover:bg-transparent",
145+
getPriorityTextAndColor(option.priority).color,
146+
customClassNames?.selectedHostList?.listItem?.changePriorityButton
147+
)}>
148+
{t(getPriorityTextAndColor(option.priority).text)}
149+
</Button>
150+
</Tooltip>
151+
152+
{isRRWeightsEnabled ? (
153+
<Button
154+
color="minimal"
155+
className={classNames(
156+
"mr-6 h-2 w-4 p-0 text-sm hover:bg-transparent",
157+
customClassNames?.selectedHostList?.listItem?.changeWeightButton
158+
)}
159+
onClick={() => {
160+
setWeightDialogOpen(true);
161+
setCurrentOption(option);
162+
}}>
163+
{option.weight ?? 100}%
164+
</Button>
165+
) : null}
166+
</>
107167
)}
108-
<p
168+
169+
<Icon
170+
name="x"
171+
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
109172
className={classNames(
110-
"text-emphasis my-auto ms-3 text-sm",
111-
customClassNames?.selectedHostList?.listItem?.name
112-
)}>
113-
{option.label}
114-
</p>
115-
<div className="ml-auto flex items-center">
116-
{option && !option.isFixed ? (
117-
<>
118-
<Tooltip content={t("change_priority")}>
119-
<Button
120-
color="minimal"
121-
onClick={() => {
122-
setPriorityDialogOpen(true);
123-
setCurrentOption(option);
124-
}}
125-
className={classNames(
126-
"mr-6 h-2 p-0 text-sm hover:bg-transparent",
127-
getPriorityTextAndColor(option.priority).color,
128-
customClassNames?.selectedHostList?.listItem?.changePriorityButton
129-
)}>
130-
{t(getPriorityTextAndColor(option.priority).text)}
131-
</Button>
132-
</Tooltip>
133-
{isRRWeightsEnabled ? (
134-
<Button
135-
color="minimal"
136-
className={classNames(
137-
"mr-6 h-2 w-4 p-0 text-sm hover:bg-transparent",
138-
customClassNames?.selectedHostList?.listItem?.changeWeightButton
139-
)}
140-
onClick={() => {
141-
setWeightDialogOpen(true);
142-
setCurrentOption(option);
143-
}}>
144-
{option.weight ?? 100}%
145-
</Button>
146-
) : (
147-
<></>
148-
)}
149-
</>
150-
) : (
151-
<></>
173+
"my-auto ml-2 h-4 w-4",
174+
customClassNames?.selectedHostList?.listItem?.removeButton
152175
)}
153-
154-
<Icon
155-
name="x"
156-
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
157-
className={classNames(
158-
"my-auto ml-2 h-4 w-4",
159-
customClassNames?.selectedHostList?.listItem?.removeButton
160-
)}
161-
/>
162-
</div>
163-
</li>
164-
</>
176+
/>
177+
</div>
178+
</li>
165179
))}
166180
</ul>
167-
{currentOption && !currentOption.isFixed ? (
181+
182+
{currentOption && !currentOption.isFixed && !currentOption.isPending && (
168183
<>
169184
<PriorityDialog
170185
isOpenDialog={priorityDialogOpen}
@@ -181,8 +196,6 @@ export const CheckedTeamSelect = ({
181196
customClassNames={customClassNames?.weightDialog}
182197
/>
183198
</>
184-
) : (
185-
<></>
186199
)}
187200
</>
188201
);

packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,34 @@ const FixedHosts = ({
161161

162162
const [isDisabled, setIsDisabled] = useState(hasActiveFixedHosts);
163163

164+
// 🔑 Helper: convert select option into Host (userId OR email)
165+
const mapToHost = (teamMember: TeamMember, isFixed: boolean, currentHosts: Host[]): Host => {
166+
const existing = currentHosts.find(
167+
(host) =>
168+
(host.userId && host.userId === parseInt(teamMember.value, 10)) ||
169+
(host.email && host.email === teamMember.value)
170+
);
171+
172+
if (/^\d+$/.test(teamMember.value)) {
173+
return {
174+
isFixed,
175+
userId: parseInt(teamMember.value, 10),
176+
priority: existing?.priority ?? 2,
177+
weight: existing?.weight ?? 100,
178+
scheduleId: existing?.scheduleId || teamMember.defaultScheduleId,
179+
};
180+
} else {
181+
return {
182+
isFixed,
183+
email: teamMember.value,
184+
isPending: true,
185+
priority: existing?.priority ?? 2,
186+
weight: existing?.weight ?? 100,
187+
scheduleId: null,
188+
};
189+
}
190+
};
191+
164192
return (
165193
<div className={classNames("mt-5 rounded-lg", customClassNames?.container)}>
166194
{!isRoundRobinEvent ? (
@@ -196,17 +224,7 @@ const FixedHosts = ({
196224
const currentHosts = getValues("hosts");
197225
setValue(
198226
"hosts",
199-
teamMembers.map((teamMember) => {
200-
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
201-
return {
202-
isFixed: true,
203-
userId: parseInt(teamMember.value, 10),
204-
priority: host?.priority ?? 2,
205-
weight: host?.weight ?? 100,
206-
// if host was already added, retain scheduleId
207-
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
208-
};
209-
}),
227+
teamMembers.map((tm) => mapToHost(tm, true, currentHosts)),
210228
{ shouldDirty: true }
211229
);
212230
}}
@@ -251,17 +269,7 @@ const FixedHosts = ({
251269
const currentHosts = getValues("hosts");
252270
setValue(
253271
"hosts",
254-
teamMembers.map((teamMember) => {
255-
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
256-
return {
257-
isFixed: true,
258-
userId: parseInt(teamMember.value, 10),
259-
priority: host?.priority ?? 2,
260-
weight: host?.weight ?? 100,
261-
// if host was already added, retain scheduleId
262-
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
263-
};
264-
}),
272+
teamMembers.map((tm) => mapToHost(tm, true, currentHosts)),
265273
{ shouldDirty: true }
266274
);
267275
}}
@@ -304,7 +312,7 @@ const RoundRobinHosts = ({
304312
}) => {
305313
const { t } = useLocale();
306314

307-
const { setValue, getValues, control, formState } = useFormContext<FormValues>();
315+
const { setValue, getValues, control } = useFormContext<FormValues>();
308316
const assignRRMembersUsingSegment = getValues("assignRRMembersUsingSegment");
309317
const isRRWeightsEnabled = useWatch({
310318
control,
@@ -315,6 +323,33 @@ const RoundRobinHosts = ({
315323
name: "rrSegmentQueryValue",
316324
});
317325

326+
const mapToHost = (teamMember: TeamMember, isFixed: boolean, currentHosts: Host[]): Host => {
327+
const existing = currentHosts.find(
328+
(host) =>
329+
(host.userId && host.userId === parseInt(teamMember.value, 10)) ||
330+
(host.email && host.email === teamMember.value)
331+
);
332+
333+
if (/^\d+$/.test(teamMember.value)) {
334+
return {
335+
isFixed,
336+
userId: parseInt(teamMember.value, 10),
337+
priority: existing?.priority ?? 2,
338+
weight: existing?.weight ?? 100,
339+
scheduleId: existing?.scheduleId || teamMember.defaultScheduleId,
340+
};
341+
} else {
342+
return {
343+
isFixed,
344+
email: teamMember.value,
345+
isPending: true,
346+
priority: existing?.priority ?? 2,
347+
weight: existing?.weight ?? 100,
348+
scheduleId: null,
349+
};
350+
}
351+
};
352+
318353
return (
319354
<div className={classNames("rounded-lg")}>
320355
<div
@@ -384,17 +419,7 @@ const RoundRobinHosts = ({
384419
const currentHosts = getValues("hosts");
385420
setValue(
386421
"hosts",
387-
teamMembers.map((teamMember) => {
388-
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
389-
return {
390-
isFixed: false,
391-
userId: parseInt(teamMember.value, 10),
392-
priority: host?.priority ?? 2,
393-
weight: host?.weight ?? 100,
394-
// if host was already added, retain scheduleId
395-
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
396-
};
397-
}),
422+
teamMembers.map((tm) => mapToHost(tm, false, currentHosts)),
398423
{ shouldDirty: true }
399424
);
400425
}}

packages/features/eventtypes/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["event
2626
export type EventTypeApps = RouterOutputs["viewer"]["apps"]["integrations"];
2727
export type Host = {
2828
isFixed: boolean;
29+
email?: string; // NEW → for invited emails
30+
isPending?: boolean; // NEW → mark invite as pending
2931
userId: number;
3032
priority: number;
3133
weight: number;

0 commit comments

Comments
 (0)