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
48 changes: 36 additions & 12 deletions apps/web/modules/event-types/components/AddMembersWithSwitch.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import { useMemo, type ComponentProps, type Dispatch, type SetStateAction } from "react";
import { useFormContext } from "react-hook-form";
import { Controller } from "react-hook-form";
import type { Options } from "react-select";

import { AddMembersWithSwitchPlatformWrapper } from "@calcom/atoms/add-members-switch/AddMembersWithSwitchPlatformWrapper";
import { AddMembersWithSwitchWebWrapper } from "./AddMembersWithSwitchWebWrapper";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { Segment } from "@calcom/features/Segment";
import type {
FormValues,
Host,
SettingsToggleClassNames,
TeamMember,
} from "@calcom/features/eventtypes/lib/types";
import { Segment } from "@calcom/features/Segment";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
import { Label } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Label, SettingsToggle } from "@calcom/ui/components/form";
import { type ComponentProps, type Dispatch, type SetStateAction, useMemo } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { Options } from "react-select";
import { AddMembersWithSwitchWebWrapper } from "./AddMembersWithSwitchWebWrapper";

import AssignAllTeamMembers from "./AssignAllTeamMembers";
import CheckedTeamSelect from "./CheckedTeamSelect";
import type { CheckedSelectOption, CheckedTeamSelectCustomClassNames } from "./CheckedTeamSelect";
import CheckedTeamSelect from "./CheckedTeamSelect";

interface IUserToValue {
id: number | null;
Expand Down Expand Up @@ -63,6 +60,7 @@ const CheckedHostField = ({
isRRWeightsEnabled,
groupId,
customClassNames,
allowEmailInvites = false,
...rest
}: {
labelText?: string;
Expand All @@ -74,7 +72,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 +86,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 @@ -191,9 +212,10 @@ export type AddMembersWithSwitchProps = {
groupId: string | null;
"data-testid"?: string;
customClassNames?: AddMembersWithSwitchCustomClassNames;
allowEmailInvites?: boolean;
};

const enum AssignmentState {
enum AssignmentState {
TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE",
TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE",
ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE = "ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE",
Expand Down Expand Up @@ -257,6 +279,7 @@ export function AddMembersWithSwitch({
isSegmentApplicable,
groupId,
customClassNames,
allowEmailInvites = false,
...rest
}: AddMembersWithSwitchProps) {
const { t } = useLocale();
Expand Down Expand Up @@ -341,6 +364,7 @@ export function AddMembersWithSwitch({
isRRWeightsEnabled={isRRWeightsEnabled}
groupId={groupId}
customClassNames={customClassNames?.teamMemberSelect}
allowEmailInvites={allowEmailInvites}
/>
</div>
</>
Expand Down
92 changes: 82 additions & 10 deletions apps/web/modules/event-types/components/CheckedTeamSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
"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 "./HostEditDialogs";
import { PriorityDialog, WeightDialog } from "./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 @@ -28,6 +41,8 @@ export type CheckedSelectOption = {
disabled?: boolean;
defaultScheduleId?: number | null;
groupId: string | null;
isEmailInvite?: boolean;
email?: string;
};

export type CheckedTeamSelectCustomClassNames = {
Expand All @@ -52,6 +67,7 @@ export const CheckedTeamSelect = ({
isRRWeightsEnabled,
customClassNames,
groupId,
allowEmailInvites = false,
...props
}: Omit<Props<CheckedSelectOption, true>, "value" | "onChange"> & {
options?: Options<CheckedSelectOption>;
Expand All @@ -60,6 +76,7 @@ export const CheckedTeamSelect = ({
isRRWeightsEnabled?: boolean;
customClassNames?: CheckedTeamSelectCustomClassNames;
groupId: string | null;
allowEmailInvites?: boolean;
}) => {
const isPlatform = useIsPlatform();
const [priorityDialogOpen, setPriorityDialogOpen] = useState(false);
Expand All @@ -79,21 +96,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
Original file line number Diff line number Diff line change
@@ -1,41 +1,38 @@
import type { TFunction } from "i18next";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { Options } from "react-select";
import { v4 as uuidv4 } from "uuid";

import type { AddMembersWithSwitchCustomClassNames } from "@calcom/web/modules/event-types/components/AddMembersWithSwitch";
import AddMembersWithSwitch, {
mapUserToValue,
} from "@calcom/web/modules/event-types/components/AddMembersWithSwitch";
import AssignAllTeamMembers from "@calcom/web/modules/event-types/components/AssignAllTeamMembers";
import type { ChildrenEventTypeSelectCustomClassNames } from "@calcom/web/modules/event-types/components/ChildrenEventTypeSelect";
import ChildrenEventTypeSelect from "@calcom/web/modules/event-types/components/ChildrenEventTypeSelect";
import { EditWeightsForAllTeamMembers } from "@calcom/web/modules/event-types/components/EditWeightsForAllTeamMembers";
import { sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
import { LearnMoreLink } from "@calcom/web/modules/event-types/components/LearnMoreLink";
import WeightDescription from "@calcom/web/modules/event-types/components/WeightDescription";
import type {
FormValues,
TeamMember,
EventTypeSetupProps,
FormValues,
Host,
SelectClassNames,
SettingsToggleClassNames,
TeamMember,
} from "@calcom/features/eventtypes/lib/types";
import { sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RRTimestampBasis, SchedulingType } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Label, Select, SettingsToggle } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { AddMembersWithSwitchCustomClassNames } from "@calcom/web/modules/event-types/components/AddMembersWithSwitch";
import AddMembersWithSwitch, {
mapUserToValue,
} from "@calcom/web/modules/event-types/components/AddMembersWithSwitch";
import AssignAllTeamMembers from "@calcom/web/modules/event-types/components/AssignAllTeamMembers";
import type { ChildrenEventTypeSelectCustomClassNames } from "@calcom/web/modules/event-types/components/ChildrenEventTypeSelect";
import ChildrenEventTypeSelect from "@calcom/web/modules/event-types/components/ChildrenEventTypeSelect";
import { EditWeightsForAllTeamMembers } from "@calcom/web/modules/event-types/components/EditWeightsForAllTeamMembers";
import { LearnMoreLink } from "@calcom/web/modules/event-types/components/LearnMoreLink";
import WeightDescription from "@calcom/web/modules/event-types/components/WeightDescription";
import type { TFunction } from "i18next";
import Link from "next/link";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { Options } from "react-select";
import { v4 as uuidv4 } from "uuid";

export type EventTeamAssignmentTabCustomClassNames = {
assignmentType?: {
Expand Down Expand Up @@ -231,6 +228,7 @@ const FixedHosts = ({
isFixed={true}
customClassNames={customClassNames?.addMembers}
onActive={handleFixedHostsActivation}
allowEmailInvites={true}
/>
</div>
</>
Expand Down Expand Up @@ -262,6 +260,7 @@ const FixedHosts = ({
automaticAddAllEnabled={!isRoundRobinEvent}
isFixed={true}
onActive={handleFixedHostsActivation}
allowEmailInvites={true}
/>
</div>
</SettingsToggle>
Expand Down Expand Up @@ -437,6 +436,7 @@ const RoundRobinHosts = ({
containerClassName={containerClassName || (assignAllTeamMembers ? "-mt-4" : "")}
onActive={() => handleMembersActivation(groupId)}
customClassNames={customClassNames?.addMembers}
allowEmailInvites={true}
/>
);
};
Expand Down Expand Up @@ -662,7 +662,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 Expand Up @@ -724,7 +730,7 @@ const Hosts = ({
),
MANAGED: <></>,
};
return !!schedulingType ? schedulingTypeRender[schedulingType] : <></>;
return schedulingType ? schedulingTypeRender[schedulingType] : <></>;
}}
/>
);
Expand Down Expand Up @@ -890,10 +896,8 @@ export const EventTeamAssignmentTab = ({
hostGroups?.length > 1 ? (
<Tooltip
content={
!!(
eventType.team?.rrTimestampBasis &&
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT
)
eventType.team?.rrTimestampBasis &&
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT
? t("rr_load_balancing_disabled")
: t("rr_load_balancing_disabled_with_groups")
}>
Expand Down
Loading
Loading