Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion apps/web/modules/event-types/components/AddMembersWithSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const CheckedHostField = ({
isRRWeightsEnabled,
groupId,
customClassNames,
allowEmailInvites = false,
...rest
}: {
labelText?: string;
Expand All @@ -74,7 +75,9 @@ const CheckedHostField = ({
helperText?: React.ReactNode | string;
isRRWeightsEnabled?: boolean;
groupId: string | null;
allowEmailInvites?: boolean;
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
const { t } = useLocale();
return (
<div className="flex flex-col rounded-md">
<div>
Expand All @@ -86,17 +89,38 @@ const CheckedHostField = ({
onChange(
options.map((option) => ({
isFixed,
userId: parseInt(option.value, 10),
userId: option.isEmailInvite ? 0 : parseInt(option.value, 10),
priority: option.priority ?? 2,
weight: option.weight ?? 100,
scheduleId: option.defaultScheduleId,
groupId: option.groupId,
isEmailInvite: option.isEmailInvite,
email: option.email,
}))
);
}}
allowEmailInvites={allowEmailInvites}
value={(value || [])
.filter(({ isFixed: _isFixed }) => isFixed === _isFixed)
.reduce((acc, host) => {
// Handle email invite hosts
if ((host as Host & { isEmailInvite?: boolean; email?: string }).isEmailInvite) {
const emailHost = host as Host & { isEmailInvite: boolean; email: string };
acc.push({
value: `email-${emailHost.email}`,
label: `${emailHost.email} (${t("invite")})`,
avatar: "",
priority: host.priority ?? 2,
isFixed,
weight: host.weight ?? 100,
groupId: host.groupId,
defaultScheduleId: null,
isEmailInvite: true,
email: emailHost.email,
});
return acc;
}

const option = options.find((member) => member.value === host.userId.toString());
if (!option) return acc;

Expand Down Expand Up @@ -194,6 +218,7 @@ export type AddMembersWithSwitchProps = {
groupId: string | null;
"data-testid"?: string;
customClassNames?: AddMembersWithSwitchCustomClassNames;
allowEmailInvites?: boolean;
};

enum AssignmentState {
Expand Down Expand Up @@ -260,6 +285,7 @@ export function AddMembersWithSwitch({
isSegmentApplicable,
groupId,
customClassNames,
allowEmailInvites = false,
...rest
}: AddMembersWithSwitchProps) {
const { t } = useLocale();
Expand Down Expand Up @@ -345,6 +371,7 @@ export function AddMembersWithSwitch({
isRRWeightsEnabled={isRRWeightsEnabled}
groupId={groupId}
customClassNames={customClassNames?.teamMemberSelect}
allowEmailInvites={allowEmailInvites}
/>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ const FixedHosts = ({
isFixed={true}
customClassNames={customClassNames?.addMembers}
onActive={handleFixedHostsActivation}
allowEmailInvites={true}
/>
</div>
</>
Expand Down Expand Up @@ -260,6 +261,7 @@ const FixedHosts = ({
automaticAddAllEnabled={!isRoundRobinEvent}
isFixed={true}
onActive={handleFixedHostsActivation}
allowEmailInvites={true}
/>
</div>
</SettingsToggle>
Expand Down Expand Up @@ -435,6 +437,7 @@ const RoundRobinHosts = ({
containerClassName={containerClassName || (assignAllTeamMembers ? "-mt-4" : "")}
onActive={() => handleMembersActivation(groupId)}
customClassNames={customClassNames?.addMembers}
allowEmailInvites={true}
/>
);
};
Expand Down Expand Up @@ -661,7 +664,13 @@ const Hosts = ({
const updatedHosts = (changedHosts: Host[]) => {
const existingHosts = getValues("hosts");
return changedHosts.map((newValue) => {
const existingHost = existingHosts.find((host: Host) => host.userId === newValue.userId);
// For email invites, match by email instead of userId (since all have userId=0)
const existingHost = existingHosts.find((host: Host) => {
if (newValue.isEmailInvite && host.isEmailInvite) {
return host.email === newValue.email;
}
return host.userId === newValue.userId;
});

return existingHost
? {
Expand Down
92 changes: 82 additions & 10 deletions packages/features/eventtypes/components/CheckedTeamSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
"use client";

import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useState } from "react";
import type { Options, Props } from "react-select";

import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types";
import { getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { getReactSelectProps } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useState } from "react";
import type { Options, Props } from "react-select";
import CreatableSelect from "react-select/creatable";

import type {
PriorityDialogCustomClassNames,
WeightDialogCustomClassNames,
} from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs";
import { PriorityDialog, WeightDialog } from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs";

// Email validation regex
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const isValidEmail = (email: string): boolean => EMAIL_REGEX.test(email.trim().toLowerCase());

// Parse comma-separated emails
const parseEmails = (input: string): string[] => {
return input
.split(/[,\s]+/)
.map((e) => e.trim().toLowerCase())
.filter((e) => isValidEmail(e));
};

export type CheckedSelectOption = {
avatar: string;
label: string;
Expand All @@ -31,6 +44,8 @@ export type CheckedSelectOption = {
disabled?: boolean;
defaultScheduleId?: number | null;
groupId: string | null;
isEmailInvite?: boolean;
email?: string;
};

export type CheckedTeamSelectCustomClassNames = {
Expand All @@ -55,6 +70,7 @@ export const CheckedTeamSelect = ({
isRRWeightsEnabled,
customClassNames,
groupId,
allowEmailInvites = false,
...props
}: Omit<Props<CheckedSelectOption, true>, "value" | "onChange"> & {
options?: Options<CheckedSelectOption>;
Expand All @@ -63,6 +79,7 @@ export const CheckedTeamSelect = ({
isRRWeightsEnabled?: boolean;
customClassNames?: CheckedTeamSelectCustomClassNames;
groupId: string | null;
allowEmailInvites?: boolean;
}) => {
const isPlatform = useIsPlatform();
const [priorityDialogOpen, setPriorityDialogOpen] = useState(false);
Expand All @@ -82,21 +99,76 @@ export const CheckedTeamSelect = ({
props.onChange(newValueAllGroups);
};

// Handle creating new options from typed emails
const handleCreateOption = (inputValue: string) => {
const emails = parseEmails(inputValue);
if (emails.length === 0) return;

const existingEmails = new Set(value.filter((v) => v.isEmailInvite).map((v) => v.email?.toLowerCase()));
const existingMemberEmails = new Set(
options.map((o) => (o as CheckedSelectOption & { email?: string }).email?.toLowerCase()).filter(Boolean)
);

const uniqueEmails = Array.from(new Set(emails));

const newOptions: CheckedSelectOption[] = uniqueEmails
.filter((email) => !existingEmails.has(email) && !existingMemberEmails.has(email))
.map((email) => ({
value: `email-${email}`,
label: `${email} (${t("invite")})`,
avatar: "",
groupId,
isEmailInvite: true,
email,
defaultScheduleId: null,
}));

if (newOptions.length > 0) {
handleSelectChange([...valueFromGroup, ...newOptions]);
}
};

// Validate if input looks like an email
const isValidNewOption = (inputValue: string): boolean => {
if (!allowEmailInvites) return false;
const emails = parseEmails(inputValue);
return emails.length > 0;
};

// Format the create option label
const formatCreateLabel = (inputValue: string) => {
const emails = parseEmails(inputValue);
if (emails.length === 0) return inputValue;
if (emails.length === 1) return `${t("invite")} ${emails[0]}`;
return `${t("invite")} ${emails.length} ${t("members").toLowerCase()}`;
};

const reactSelectProps = getReactSelectProps<CheckedSelectOption, true>({
components: {},
});

return (
<>
<Select
<CreatableSelect<CheckedSelectOption, true>
{...reactSelectProps}
{...props}
name={props.name}
placeholder={props.placeholder || t("select")}
isSearchable={true}
options={options}
value={valueFromGroup}
onChange={handleSelectChange}
onCreateOption={allowEmailInvites ? handleCreateOption : undefined}
isValidNewOption={isValidNewOption}
formatCreateLabel={formatCreateLabel}
isMulti
className={customClassNames?.hostsSelect?.select}
innerClassNames={{
...customClassNames?.hostsSelect?.innerClassNames,
control: "rounded-md",
className={classNames("text-sm", customClassNames?.hostsSelect?.select)}
classNames={{
control: () => "rounded-md",
option: (state) => {
const data = state.data as CheckedSelectOption;
return data.isEmailInvite ? "italic text-subtle" : "";
},
}}
/>
{/* This class name conditional looks a bit odd but it allows a seamless transition when using autoanimate
Expand Down
5 changes: 4 additions & 1 deletion packages/features/eventtypes/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
eventTypeBookingFields,
eventTypeColor,
} from "@calcom/prisma/zod-utils";
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import type { UserProfile } from "@calcom/types/UserProfile";
import type { z } from "zod";
Expand Down Expand Up @@ -50,6 +51,8 @@ export type Host = {
scheduleId?: number | null;
groupId: string | null;
location?: HostLocation | null;
isEmailInvite?: boolean;
email?: string;
};
export type TeamMember = {
value: string;
Expand Down Expand Up @@ -495,7 +498,7 @@ export type SelectClassNames = {
};

// Re-export schemas from server-safe location
export { EventTypeDuplicateInput, createEventTypeInput } from "./schemas";
export { createEventTypeInput, EventTypeDuplicateInput } from "./schemas";

export type FormValidationResult = {
isValid: boolean;
Expand Down
Loading