diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts index 6b0fa894f19ca2..b6f04f4f35d4bb 100644 --- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -20,6 +20,7 @@ import { getBulkTeamEventTypes, getBulkUserEventTypes, getEventTypeById, + getEventTypeByIdWithTeamMembers, getPublicEvent, type PublicEventType, TUpdateEventTypeInputSchema, @@ -99,7 +100,7 @@ export class EventTypesAtomService { ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) : false; - const eventType = await getEventTypeById({ + const eventType = await getEventTypeByIdWithTeamMembers({ currentOrganizationId: this.usersService.getUserMainOrgId(user), eventTypeId, userId: user.id, @@ -121,11 +122,9 @@ export class EventTypesAtomService { } } - // note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type - const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged); - eventType.teamMembers = onlyManagedTeamMembers; + const onlyManagedTeamMembers = eventType.teamMembers.filter((member) => member.isPlatformManaged); - return eventType; + return { ...eventType, teamMembers: onlyManagedTeamMembers }; } async getUserEventTypes(userId: number) { diff --git a/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx b/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx index 216487786398d1..1321c00271c44c 100644 --- a/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx +++ b/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx @@ -4,13 +4,15 @@ import type { FormValues, Host, SettingsToggleClassNames, - TeamMember, } from "@calcom/features/eventtypes/lib/types"; +import { useSearchTeamMembers } from "@calcom/features/eventtypes/lib/useSearchTeamMembers"; import { Segment } from "./Segment"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import { AssignedSearchInput } from "@calcom/features/eventtypes/components/AssignedSearchInput"; import { Label, SettingsToggle } from "@calcom/ui/components/form"; -import { type ComponentProps, type Dispatch, type SetStateAction, useMemo } from "react"; +import { useDebounce } from "@calcom/lib/hooks/useDebounce"; +import { type ComponentProps, type Dispatch, type SetStateAction, useMemo, useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import type { Options } from "react-select"; import { AddMembersWithSwitchWebWrapper } from "./AddMembersWithSwitchWebWrapper"; @@ -42,7 +44,7 @@ export const mapUserToValue = ( defaultScheduleId, }); -const sortByLabel = (a: ReturnType, b: ReturnType) => { +const sortByLabel = (a: { label: string }, b: { label: string }) => { if (a.label < b.label) { return -1; } @@ -63,6 +65,12 @@ const CheckedHostField = ({ isRRWeightsEnabled, groupId, customClassNames, + onSearchChange, + onMenuScrollToBottom, + isLoadingMore, + hasNextPageSelected, + isFetchingNextPageSelected, + fetchNextPageSelected, ...rest }: { labelText?: string; @@ -74,7 +82,14 @@ const CheckedHostField = ({ helperText?: React.ReactNode | string; isRRWeightsEnabled?: boolean; groupId: string | null; + onSearchChange?: (search: string) => void; + onMenuScrollToBottom?: () => void; + isLoadingMore?: boolean; + hasNextPageSelected?: boolean; + isFetchingNextPageSelected?: boolean; + fetchNextPageSelected?: () => void; } & Omit>, "onChange" | "value">) => { + const { t } = useLocale(); return (
@@ -98,16 +113,30 @@ const CheckedHostField = ({ .filter(({ isFixed: _isFixed }) => isFixed === _isFixed) .reduce((acc, host) => { const option = options.find((member) => member.value === host.userId.toString()); - if (!option) return acc; - acc.push({ - ...option, - priority: host.priority ?? 2, - isFixed, - weight: host.weight ?? 100, - groupId: host.groupId, - }); + // Use option data if available, otherwise create a fallback from host metadata + // (host data from usePaginatedAssignmentHosts includes name/email/avatarUrl at runtime) + const hostAny = host as Host & { name?: string | null; email?: string; avatarUrl?: string | null }; + const displayOption: CheckedSelectOption = option + ? { + ...option, + priority: host.priority ?? 2, + isFixed, + weight: host.weight ?? 100, + groupId: host.groupId, + } + : { + value: host.userId.toString(), + label: hostAny.name || hostAny.email || t("team_member"), + avatar: hostAny.avatarUrl || "", + priority: host.priority ?? 2, + isFixed, + weight: host.weight ?? 100, + groupId: host.groupId, + defaultScheduleId: host.scheduleId, + }; + acc.push(displayOption); return acc; }, [] as CheckedSelectOption[])} controlShouldRenderValue={false} @@ -116,6 +145,13 @@ const CheckedHostField = ({ isRRWeightsEnabled={isRRWeightsEnabled} customClassNames={customClassNames} groupId={groupId} + hosts={value} + onSearchChange={onSearchChange} + onMenuScrollToBottom={onMenuScrollToBottom} + isLoadingMore={isLoadingMore} + hasNextPageSelected={hasNextPageSelected} + isFetchingNextPageSelected={isFetchingNextPageSelected} + fetchNextPageSelected={fetchNextPageSelected} {...rest} />
@@ -130,7 +166,6 @@ function MembersSegmentWithToggle({ rrSegmentQueryValue, setRrSegmentQueryValue, className, - filterMemberIds, }: { teamId: number; assignRRMembersUsingSegment: boolean; @@ -138,7 +173,6 @@ function MembersSegmentWithToggle({ rrSegmentQueryValue: AttributesQueryValue | null; setRrSegmentQueryValue: (value: AttributesQueryValue) => void; className?: string; - filterMemberIds?: number[]; }) { const { t } = useLocale(); const onQueryValueChange = ({ queryValue }: { queryValue: AttributesQueryValue }) => { @@ -164,7 +198,6 @@ function MembersSegmentWithToggle({ queryValue={rrSegmentQueryValue} onQueryValueChange={onQueryValueChange} className={className} - filterMemberIds={filterMemberIds} /> )} @@ -179,7 +212,6 @@ export type AddMembersWithSwitchCustomClassNames = { }; export type AddMembersWithSwitchProps = { - teamMembers: TeamMember[]; value: Host[]; onChange: (hosts: Host[]) => void; assignAllTeamMembers: boolean; @@ -194,6 +226,12 @@ export type AddMembersWithSwitchProps = { groupId: string | null; "data-testid"?: string; customClassNames?: AddMembersWithSwitchCustomClassNames; + hasNextPageSelected?: boolean; + isFetchingNextPageSelected?: boolean; + fetchNextPageSelected?: () => void; + assignedSearchValue?: string; + onAssignedSearchChange?: (value: string) => void; + isSearchingAssigned?: boolean; }; enum AssignmentState { @@ -246,7 +284,6 @@ function useSegmentState() { } export function AddMembersWithSwitch({ - teamMembers, value, onChange, assignAllTeamMembers, @@ -260,10 +297,27 @@ export function AddMembersWithSwitch({ isSegmentApplicable, groupId, customClassNames, + hasNextPageSelected, + isFetchingNextPageSelected, + fetchNextPageSelected, ...rest }: AddMembersWithSwitchProps) { const { t } = useLocale(); const { setValue } = useFormContext(); + + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); + + const { + options: searchOptions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useSearchTeamMembers({ + teamId, + search: debouncedSearch, + enabled: true, + }); const { assignRRMembersUsingSegment, setAssignRRMembersUsingSegment, @@ -306,7 +360,6 @@ export function AddMembersWithSwitch({ setAssignRRMembersUsingSegment={setAssignRRMembersUsingSegment} rrSegmentQueryValue={rrSegmentQueryValue} setRrSegmentQueryValue={setRrSegmentQueryValue} - filterMemberIds={value.filter((host) => !host.isFixed).map((host) => host.userId)} />
)} @@ -328,6 +381,14 @@ export function AddMembersWithSwitch({ /> )} + {rest.onAssignedSearchChange && ( + + )}
({ - ...member, + options={searchOptions + .map((opt) => ({ + ...opt, groupId: groupId, })) .sort(sortByLabel)} @@ -345,6 +406,14 @@ export function AddMembersWithSwitch({ isRRWeightsEnabled={isRRWeightsEnabled} groupId={groupId} customClassNames={customClassNames?.teamMemberSelect} + onSearchChange={setSearch} + onMenuScrollToBottom={() => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }} + isLoadingMore={isFetchingNextPage} + hasNextPageSelected={hasNextPageSelected} + isFetchingNextPageSelected={isFetchingNextPageSelected} + fetchNextPageSelected={fetchNextPageSelected} />
diff --git a/apps/web/modules/event-types/components/EditWeightsForAllTeamMembers.tsx b/apps/web/modules/event-types/components/EditWeightsForAllTeamMembers.tsx index 1ce9f2ff6d66dd..6860cf083671d6 100644 --- a/apps/web/modules/event-types/components/EditWeightsForAllTeamMembers.tsx +++ b/apps/web/modules/event-types/components/EditWeightsForAllTeamMembers.tsx @@ -1,16 +1,15 @@ "use client"; -import { useTeamMembersWithSegmentPlatform } from "@calcom/atoms/event-types/hooks/useTeamMembersWithSegmentPlatform"; -import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; -import type { Host, TeamMember } from "@calcom/features/eventtypes/lib/types"; +import { useHosts } from "@calcom/features/eventtypes/lib/HostsContext"; +import type { Host } from "@calcom/features/eventtypes/lib/types"; import ServerTrans from "@calcom/lib/components/ServerTrans"; import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import { trpc } from "@calcom/trpc/react"; import { Avatar } from "@calcom/ui/components/avatar"; import { Button, buttonClasses } from "@calcom/ui/components/button"; import { TextField } from "@calcom/ui/components/form"; -import { ChevronDownIcon, InfoIcon, SearchIcon, UploadIcon } from "@coss/ui/icons"; import { Sheet, SheetBody, @@ -21,12 +20,20 @@ import { SheetTitle, } from "@calcom/ui/components/sheet"; import { showToast } from "@calcom/ui/components/toast"; -import { useTeamMembersWithSegment } from "@calcom/web/modules/event-types/hooks/useTeamMembersWithSegment"; +import { ChevronDownIcon, InfoIcon, SearchIcon, UploadIcon } from "@coss/ui/icons"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type WeightMember = { + value: string; + label: string; + avatar: string; + email: string; + weight?: number; +}; type TeamMemberItemProps = { - member: Omit & { weight?: number }; + member: WeightMember; onWeightChange: (memberId: string, weight: number) => void; }; @@ -94,19 +101,21 @@ const TeamMemberItem = ({ member, onWeightChange }: TeamMemberItemProps) => { }; interface Props { - teamMembers: TeamMember[]; value: Host[]; onChange: (hosts: Host[]) => void; + assignAllTeamMembers: boolean; assignRRMembersUsingSegment: boolean; + eventTypeId: number; teamId?: number; queryValue?: AttributesQueryValue | null; } export const EditWeightsForAllTeamMembers = ({ - teamMembers: initialTeamMembers, value, onChange, + assignAllTeamMembers, assignRRMembersUsingSegment, + eventTypeId, teamId, queryValue, }: Props) => { @@ -114,61 +123,133 @@ export const EditWeightsForAllTeamMembers = ({ const { t } = useLocale(); const [searchQuery, setSearchQuery] = useState(""); - const isPlatform = useIsPlatform(); - - const useTeamMembersHook = isPlatform ? useTeamMembersWithSegmentPlatform : useTeamMembersWithSegment; - - const { teamMembers, localWeightsInitialValues } = useTeamMembersHook({ - initialTeamMembers, - assignRRMembersUsingSegment, - teamId, - queryValue, - value, - }); + // Single query that loads all members — handles assignAllTeamMembers, segment filtering, and assigned hosts + const { data, isPending: isLoading } = trpc.viewer.eventTypes.exportHostsForWeights.useQuery( + { + eventTypeId, + teamId, + assignAllTeamMembers, + assignRRMembersUsingSegment, + attributesQueryValue: queryValue, + }, + { enabled: isOpen } + ); + const allMembers = data?.members ?? []; + + // Build a map of current RR host weights from form state for quick lookup + const hostWeightsMap = useMemo(() => { + const map = new Map(); + for (const host of value) { + if (!host.isFixed) { + map.set(host.userId, host.weight ?? 100); + } + } + return map; + }, [value]); - const [localWeights, setLocalWeights] = useState>(localWeightsInitialValues); + const [localWeights, setLocalWeights] = useState>({}); const [uploadErrors, setUploadErrors] = useState>([]); const [isErrorsExpanded, setIsErrorsExpanded] = useState(true); + const prevIsOpenRef = useRef(false); + + // Initialize local weights only when the sheet transitions from closed to open + useEffect(() => { + if (isOpen && !prevIsOpenRef.current) { + const initial: Record = {}; + for (const host of value) { + if (!host.isFixed) { + initial[String(host.userId)] = host.weight ?? 100; + } + } + setLocalWeights(initial); + } + prevIsOpenRef.current = isOpen; + }, [isOpen, value]); + const handleWeightChange = (memberId: string, weight: number) => { setLocalWeights((prev) => ({ ...prev, [memberId]: weight })); }; + const { updateHost } = useHosts(); + const handleSave = () => { - // Create a map of existing hosts for easy lookup - const existingHostsMap = new Map( - value.filter((host) => !host.isFixed).map((host) => [host.userId.toString(), host]) - ); - - // Create the updated value by processing all team members - const updatedValue = teamMembers - .map((member) => { - const existingHost = existingHostsMap.get(member.value); - if (!existingHost) return null; - return { - ...existingHost, - userId: parseInt(member.value, 10), - isFixed: existingHost?.isFixed ?? false, - priority: existingHost?.priority ?? 0, - weight: localWeights[member.value] ?? existingHost?.weight ?? 100, - groupId: existingHost?.groupId ?? null, - }; - }) - .filter(Boolean) as Host[]; - - onChange(updatedValue); + const originalWeightMap = new Map(); + for (const host of value) { + if (!host.isFixed) { + originalWeightMap.set(host.userId, host.weight ?? 100); + } + } + for (const m of allMembers) { + if (!originalWeightMap.has(m.userId)) { + originalWeightMap.set(m.userId, m.weight ?? 100); + } + } + + for (const [userIdStr, newWeight] of Object.entries(localWeights)) { + const userId = parseInt(userIdStr, 10); + const originalWeight = originalWeightMap.get(userId) ?? 100; + if (newWeight !== originalWeight) { + updateHost(userId, { weight: newWeight }); + } + } + setIsOpen(false); }; - const handleDownloadCsv = () => { - const csvData = teamMembers.map((member) => ({ - id: member.value, - name: member.label, - email: member.email, - weight: localWeights[member.value] ?? 100, + const displayMembers = useMemo((): WeightMember[] => { + let members = allMembers.map((m) => ({ + value: String(m.userId), + label: m.name || m.email || "", + avatar: m.avatarUrl || "", + email: m.email, + weight: localWeights[String(m.userId)] ?? m.weight ?? hostWeightsMap.get(m.userId) ?? 100, })); - downloadAsCsv(csvData, "team-members-weights.csv"); - }; + + if (searchQuery) { + const q = searchQuery.toLowerCase(); + members = members.filter((m) => m.label.toLowerCase().includes(q) || m.email.toLowerCase().includes(q)); + } + + return members; + }, [allMembers, localWeights, hostWeightsMap, searchQuery]); + + const utils = trpc.useUtils(); + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownloadCsv = useCallback(async () => { + setIsDownloading(true); + try { + const { members } = await utils.viewer.eventTypes.exportHostsForWeights.fetch({ + eventTypeId, + teamId, + assignAllTeamMembers, + assignRRMembersUsingSegment, + attributesQueryValue: queryValue, + }); + + downloadAsCsv( + members.map((m) => ({ + id: m.userId, + name: m.name || m.email || "", + email: m.email, + weight: localWeights[String(m.userId)] ?? m.weight ?? hostWeightsMap.get(m.userId) ?? 100, + })), + "team-members-weights.csv" + ); + } finally { + setIsDownloading(false); + } + }, [ + utils, + eventTypeId, + teamId, + assignAllTeamMembers, + assignRRMembersUsingSegment, + queryValue, + localWeights, + hostWeightsMap, + ]); const handleUploadCsv = (e: React.ChangeEvent) => { if (!e.target.files?.length) return; @@ -184,6 +265,8 @@ export const EditWeightsForAllTeamMembers = ({ const newWeights: Record = { ...localWeights }; const newErrors: Array<{ email: string; error: string }> = []; + const emailToMember = new Map(allMembers.map((m) => [m.email, m])); + // Skip header row for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); @@ -192,7 +275,7 @@ export const EditWeightsForAllTeamMembers = ({ const [, , email, weightStr] = line.split(","); if (!email || !weightStr) continue; - const member = teamMembers.find((m) => m.email === email); + const member = emailToMember.get(email); if (!member) { newErrors.push({ email, error: t("member_not_found") }); continue; @@ -204,7 +287,7 @@ export const EditWeightsForAllTeamMembers = ({ continue; } - newWeights[member.value] = weight; + newWeights[String(member.userId)] = weight; } setLocalWeights(newWeights); @@ -221,22 +304,6 @@ export const EditWeightsForAllTeamMembers = ({ reader.readAsText(file); }; - const filteredMembers = useMemo(() => { - return teamMembers - .map((member) => ({ - ...member, - weight: localWeights[member.value], - })) - .filter( - (member) => - member.label.toLowerCase().includes(searchQuery.toLowerCase()) || - member.email.toLowerCase().includes(searchQuery.toLowerCase()) - ) - .filter((member) => { - return value.some((host) => !host.isFixed && host.userId === parseInt(member.value, 10)); - }); - }, [teamMembers, localWeights, searchQuery, value]); - return ( <> setSearchQuery(e.target.value.toLowerCase())} - addOnLeading={ - - } + onChange={(e) => setSearchQuery(e.target.value)} + addOnLeading={} /> -
- {filteredMembers.map((member) => ( +
+ {displayMembers.map((member) => ( ))} - {filteredMembers.length === 0 && ( + {isLoading &&
{t("loading")}
} + {displayMembers.length === 0 && !isLoading && (
{t("no_members_found")}
)}
@@ -329,8 +400,6 @@ export const EditWeightsForAllTeamMembers = ({ - +
); diff --git a/apps/web/modules/event-types/components/EventType.tsx b/apps/web/modules/event-types/components/EventType.tsx index a7e934cb7a3b9b..e9f1e73f8a235e 100644 --- a/apps/web/modules/event-types/components/EventType.tsx +++ b/apps/web/modules/event-types/components/EventType.tsx @@ -12,6 +12,7 @@ import type { FormValues, EventTypeApps, } from "@calcom/features/eventtypes/lib/types"; +import { HostsProvider } from "@calcom/features/eventtypes/lib/HostsContext"; import type { customInputSchema } from "@calcom/prisma/zod-utils"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Form } from "@calcom/ui/components/form"; @@ -46,7 +47,6 @@ const tabs = [ ] as const; export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]; -export type TeamMembers = RouterOutputs["viewer"]["eventTypes"]["get"]["teamMembers"]; export type EventTypeComponentProps = EventTypeSetupProps & { allActiveWorkflows?: Workflow[]; @@ -105,7 +105,9 @@ export const EventType = ({ tabsNavigation={tabsNavigation} saveButtonRef={saveButtonRef}>
-
{tabMap[tabName]}
+ +
{tabMap[tabName]?.()}
+
{children} diff --git a/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx b/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx index c3f72db123e521..d36fafcbb382a3 100644 --- a/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx +++ b/apps/web/modules/event-types/components/EventTypeWebWrapper.tsx @@ -146,7 +146,7 @@ const EventTypeWeb = ({ const leaveWithoutAssigningHosts = useRef(false); const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState(false); const [pendingRoute, setPendingRoute] = useState(""); - const { eventType, locationOptions, team, teamMembers, destinationCalendar } = rest; + const { eventType, locationOptions, team, destinationCalendar } = rest; const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState([]); const { data: eventTypeApps, isPending: isPendingApps } = trpc.viewer.apps.integrations.useQuery({ extendsFeature: "EventType", @@ -159,15 +159,16 @@ const EventTypeWeb = ({ onSuccess: async () => { const currentValues = form.getValues(); - currentValues.children = currentValues.children.map((child) => ({ - ...child, - created: true, - })); + // Reset pending children changes after successful save + currentValues.pendingChildrenChanges = { + childrenToAdd: [], + childrenToRemove: [], + childrenToUpdate: [], + }; currentValues.assignAllTeamMembers = currentValues.assignAllTeamMembers || false; // Reset the form with these values as new default values to ensure the correct comparison for dirtyFields eval form.reset(currentValues); - revalidateEventTypeEditPage(eventType.id); if (eventType.team?.slug) { // When an event-type is updated, // guests could still hit a stale cache and see the old page. @@ -182,6 +183,7 @@ const EventTypeWeb = ({ async onSettled() { await utils.viewer.eventTypes.get.invalidate(); await utils.viewer.eventTypes.getByViewer.invalidate(); + revalidateEventTypeEditPage(eventType.id); }, onError: (err) => { let message = ""; @@ -208,6 +210,12 @@ const EventTypeWeb = ({ const { form, handleSubmit } = useEventTypeForm({ eventType, onSubmit: updateMutation.mutate }); const slug = form.watch("slug") ?? eventType.slug; + const pendingHostChanges = form.watch("pendingHostChanges"); + const effectiveHostCount = pendingHostChanges?.clearAllHosts + ? pendingHostChanges.hostsToAdd.length + : eventType._count.hosts + + (pendingHostChanges?.hostsToAdd.length ?? 0) - + (pendingHostChanges?.hostsToRemove.length ?? 0); const { data: allActiveWorkflows } = trpc.viewer.workflows.getAllActiveWorkflows.useQuery({ eventType: { @@ -226,34 +234,33 @@ const EventTypeWeb = ({ eventType.slug }`; + // Use functions to lazily render tabs - only the active tab is instantiated + // This prevents all tab hooks (including watch("hosts") with 700 hosts) from running on every render const tabMap = { - setup: ( + setup: () => ( ), - availability: ( + availability: () => ( ), - team: ( + team: () => ( ), - limits: , - advanced: ( + limits: () => , + advanced: () => ( ), - instant: , - recurring: , - apps: ( + instant: () => , + recurring: () => , + apps: () => ( ), - workflows: + workflows: () => allActiveWorkflows && canReadWorkflows ? ( ) : ( <> ), - webhooks: , - ai: , + webhooks: () => , + ai: () => , } as const; useHandleRouteChange({ @@ -287,8 +294,8 @@ const EventTypeWeb = ({ isTeamEventTypeDeleted: isTeamEventTypeDeleted.current, isleavingWithoutAssigningHosts: leaveWithoutAssigningHosts.current, isTeamEventType: !!team, - assignedUsers: eventType.children, - hosts: eventType.hosts, + childrenCount: eventType.childrenCount, + hostCount: effectiveHostCount, assignAllTeamMembers: eventType.assignAllTeamMembers, isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED, onError: (url) => { diff --git a/apps/web/modules/event-types/components/Segment.test.tsx b/apps/web/modules/event-types/components/Segment.test.tsx index 81e1d0df33855b..4802b6dfd4f5ec 100644 --- a/apps/web/modules/event-types/components/Segment.test.tsx +++ b/apps/web/modules/event-types/components/Segment.test.tsx @@ -33,13 +33,23 @@ const mockGetMatchingTeamMembers = ( isPending: true; } ) => { + const infiniteData = arg.data + ? { + pages: [{ ...arg.data, nextCursor: undefined, total: arg.data.result?.length ?? 0 }], + pageParams: [undefined], + } + : undefined; ( - trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery as Mock< - typeof trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery - > + trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useInfiniteQuery as Mock ) // eslint-disable-next-line @typescript-eslint/no-explicit-any - .mockReturnValue(arg as any); + .mockReturnValue({ + data: infiniteData, + isPending: arg.isPending, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + } as any); }; const mockAttributesWithSingleSelect = () => { @@ -72,7 +82,7 @@ vi.mock("@calcom/trpc/react", () => ({ }, attributes: { findTeamMembersMatchingAttributeLogic: { - useQuery: vi.fn(), + useInfiniteQuery: vi.fn(), }, }, }, @@ -118,6 +128,8 @@ describe("Segment", () => { mainWarnings: null, fallbackWarnings: null, troubleshooter: undefined, + nextCursor: undefined, + total: 1, result: [ { id: 1, @@ -174,6 +186,8 @@ describe("Segment", () => { mainWarnings: null, fallbackWarnings: null, troubleshooter: undefined, + nextCursor: undefined, + total: 1, result: [ { id: 1, @@ -197,6 +211,8 @@ describe("Segment", () => { mainWarnings: null, fallbackWarnings: null, troubleshooter: undefined, + nextCursor: undefined, + total: 0, result: [] as MatchingTeamMembersData["result"], }, isPending: false, diff --git a/apps/web/modules/event-types/components/Segment.tsx b/apps/web/modules/event-types/components/Segment.tsx index e8166ad99d2f0d..2550e08c46491e 100644 --- a/apps/web/modules/event-types/components/Segment.tsx +++ b/apps/web/modules/event-types/components/Segment.tsx @@ -6,12 +6,17 @@ import { withRaqbSettingsAndWidgets, } from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig"; import { getQueryBuilderConfigForAttributes } from "@calcom/app-store/routing-forms/lib/getQueryBuilderConfig"; +import { useFetchMoreOnScroll } from "@calcom/features/eventtypes/lib/useFetchMoreOnScroll"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { isEqual } from "@calcom/lib/isEqual"; import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; import { type RouterOutputs, trpc } from "@calcom/trpc/react"; +import { AssignedSearchInput } from "@calcom/features/eventtypes/components/AssignedSearchInput"; import cn from "@calcom/ui/classNames"; -import { useCallback, useState } from "react"; +import { Icon } from "@calcom/ui/components/icon"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useDebounce } from "@calcom/lib/hooks/useDebounce"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { BuilderProps, ImmutableTree, JsonTree } from "react-awesome-query-builder"; import { Builder, Utils as QbUtils, Query } from "react-awesome-query-builder"; @@ -32,14 +37,12 @@ function SegmentWithAttributes({ queryValue: initialQueryValue, onQueryValueChange, className, - filterMemberIds, }: { attributes: Attributes; teamId: number; queryValue: AttributesQueryValue | null; onQueryValueChange: ({ queryValue }: { queryValue: AttributesQueryValue }) => void; className?: string; - filterMemberIds?: number[]; }) { const attributesQueryBuilderConfig = getQueryBuilderConfigForAttributes({ attributes, @@ -91,22 +94,26 @@ function SegmentWithAttributes({ />
- +
); } +const MATCHING_MEMBERS_PAGE_SIZE = 20; + function MatchingTeamMembers({ teamId, queryValue, - filterMemberIds, }: { teamId: number; queryValue: AttributesQueryValue | null; - filterMemberIds?: number[]; }) { const { t } = useLocale(); + const scrollContainerRef = useRef(null); + + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); // Check if queryValue has valid children properties value const hasValidValue = queryValue?.children1 @@ -115,18 +122,31 @@ function MatchingTeamMembers({ ) : false; - const { data: matchingTeamMembersWithResult, isPending } = - trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery( + const { data, isPending, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, isError } = + trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useInfiniteQuery( { teamId, attributesQueryValue: queryValue, _enablePerf: true, + limit: MATCHING_MEMBERS_PAGE_SIZE, + search: debouncedSearch || undefined, }, { enabled: hasValidValue, + getNextPageParam: (lastPage) => lastPage.nextCursor, + placeholderData: keepPreviousData, } ); + useFetchMoreOnScroll(scrollContainerRef, hasNextPage ?? false, isFetchingNextPage, fetchNextPage); + + const allMatchingTeamMembers = useMemo( + () => data?.pages.flatMap((page) => page.result ?? []) ?? [], + [data] + ); + + const total = data?.pages[0]?.total ?? 0; + if (!hasValidValue) { return (
@@ -137,6 +157,16 @@ function MatchingTeamMembers({ ); } + if (isError) { + return ( +
+
+ {t("something_went_wrong")} +
+
+ ); + } + if (isPending) { return (
{t("something_went_wrong")}; - const { result: allMatchingTeamMembers } = matchingTeamMembersWithResult; - - const matchingTeamMembers = filterMemberIds - ? allMatchingTeamMembers?.filter((member) => filterMemberIds.includes(member.id)) - : allMatchingTeamMembers; - return (
- {t("x_matching_members", { x: matchingTeamMembers?.length ?? 0 })} + {t("x_matching_members", { x: total })} +
+ +
+
    + {allMatchingTeamMembers.map((member) => ( +
  • +
    + {member.name} + ({member.email}) +
    +
  • + ))} +
+ {isFetchingNextPage && ( +
+ +
+ )}
-
    - {matchingTeamMembers?.map((member) => ( -
  • -
    - {member.name} - ({member.email}) -
    -
  • - ))} -
); } @@ -190,13 +225,11 @@ export function Segment({ queryValue, onQueryValueChange, className, - filterMemberIds, }: { teamId: number; queryValue: AttributesQueryValue | null; onQueryValueChange: ({ queryValue }: { queryValue: AttributesQueryValue }) => void; className?: string; - filterMemberIds?: number[]; }) { const { attributes, isPending } = useAttributes(teamId); const { t } = useLocale(); @@ -213,7 +246,6 @@ export function Segment({ queryValue={queryValue} onQueryValueChange={onQueryValueChange} className={className} - filterMemberIds={filterMemberIds} /> ); } diff --git a/apps/web/modules/event-types/components/__tests__/AddMembersWithSwitch.test.tsx b/apps/web/modules/event-types/components/__tests__/AddMembersWithSwitch.test.tsx index 1e72b81705e1da..42730c8322e6ab 100644 --- a/apps/web/modules/event-types/components/__tests__/AddMembersWithSwitch.test.tsx +++ b/apps/web/modules/event-types/components/__tests__/AddMembersWithSwitch.test.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { FormProvider, useForm } from "react-hook-form"; import { describe, expect, it, vi } from "vitest"; -import type { Host, TeamMember } from "@calcom/features/eventtypes/lib/types"; +import type { Host } from "@calcom/features/eventtypes/lib/types"; import type { AddMembersWithSwitchProps } from "../AddMembersWithSwitch"; import { AddMembersWithSwitch } from "../AddMembersWithSwitch"; @@ -22,22 +22,33 @@ vi.mock("@calcom/web/modules/event-types/components/Segment", () => ({ )), })); -const mockTeamMembers: TeamMember[] = [ - { - value: "1", - label: "John Doe", - avatar: "avatar1.jpg", - email: "john@example.com", - defaultScheduleId: 1, - }, - { - value: "2", - label: "Jane Smith", - avatar: "avatar2.jpg", - email: "jane@example.com", - defaultScheduleId: 2, - }, -]; +// Mock useSearchTeamMembers +vi.mock("@calcom/features/eventtypes/lib/useSearchTeamMembers", () => ({ + useSearchTeamMembers: () => ({ + options: [ + { + value: "1", + label: "John Doe", + avatar: "avatar1.jpg", + email: "john@example.com", + defaultScheduleId: 1, + groupId: null, + }, + { + value: "2", + label: "Jane Smith", + avatar: "avatar2.jpg", + email: "jane@example.com", + defaultScheduleId: 2, + groupId: null, + }, + ], + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + }), +})); // Mock trpc vi.mock("@calcom/trpc/react", () => ({ @@ -97,7 +108,6 @@ const renderComponent = ({ describe("AddMembersWithSwitch", () => { const defaultProps = { - teamMembers: mockTeamMembers, value: [] as Host[], onChange: vi.fn(), onActive: vi.fn(), diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 86de74e4d5b1e5..1db81163c4c3ff 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -1,7 +1,7 @@ "use client"; import { useSession } from "next-auth/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useFormContext } from "react-hook-form"; import type { CSSObjectWithLabel } from "react-select"; import { components } from "react-select"; @@ -32,6 +32,9 @@ import { Skeleton } from "@calcom/ui/components/skeleton"; import { showToast } from "@calcom/ui/components/toast"; import type { FormValues, Host, HostLocation } from "@calcom/features/eventtypes/lib/types"; +import { useHosts } from "@calcom/features/eventtypes/lib/HostsContext"; +import { usePaginatedAssignmentHosts } from "@calcom/features/eventtypes/lib/usePaginatedAssignmentHosts"; +import { useFetchMoreOnScroll } from "@calcom/features/eventtypes/lib/useFetchMoreOnScroll"; import type { TLocationOptions } from "./Locations"; type HostWithLocationOptions = { @@ -581,30 +584,6 @@ const LocationInputField = ({ eventLocationType, inputValue, setInputValue }: Lo ); }; -const useFetchMoreOnScroll = ( - containerRef: React.RefObject, - hasNextPage: boolean | undefined, - isFetchingNextPage: boolean, - fetchNextPage: () => void -) => { - const handleScroll = useCallback(() => { - const container = containerRef.current; - if (!container || !hasNextPage || isFetchingNextPage) return; - - const { scrollHeight, scrollTop, clientHeight } = container; - if (scrollHeight - scrollTop - clientHeight < 100) { - fetchNextPage(); - } - }, [containerRef, hasNextPage, isFetchingNextPage, fetchNextPage]); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - container.addEventListener("scroll", handleScroll); - return () => container.removeEventListener("scroll", handleScroll); - }, [handleScroll, containerRef]); -}; const buildFullLocationOptions = ( locationOptions: TLocationOptions, @@ -751,25 +730,22 @@ const normalizeHostLocation = (host: Host, eventTypeId: number): Host => { const useHostLocationHandlers = ( formMethods: ReturnType>, hosts: Host[], + serverHosts: Host[], + setHosts: (serverHosts: Host[], newHosts: Host[]) => void, eventTypeId: number ) => { const handleToggle = (checked: boolean) => { formMethods.setValue("enablePerHostLocations", checked, { shouldDirty: true }); if (!checked) { - formMethods.setValue( - "hosts", - hosts.map((host) => ({ ...host, location: null })), - { shouldDirty: true } - ); + // Use setHosts from context instead of form setValue for performance + setHosts(serverHosts, hosts.map((host) => ({ ...host, location: null }))); } }; const handleLocationChange = (userId: number, location: HostLocation | null) => { - formMethods.setValue( - "hosts", - hosts.map((h) => normalizeHostLocation(h.userId === userId ? { ...h, location } : h, eventTypeId)), - { shouldDirty: true } - ); + setHosts(serverHosts, hosts.map((h) => + normalizeHostLocation(h.userId === userId ? { ...h, location } : h, eventTypeId) + )); }; return { handleToggle, handleLocationChange }; @@ -777,8 +753,9 @@ const useHostLocationHandlers = ( const useMassApplyMutation = ( eventTypeId: number, - formMethods: ReturnType>, hosts: Host[], + serverHosts: Host[], + setHosts: (serverHosts: Host[], newHosts: Host[]) => void, onSuccess: () => void ) => { const { t } = useLocale(); @@ -810,7 +787,8 @@ const useMassApplyMutation = ( phoneNumber: phoneNumber ?? null, }, })); - formMethods.setValue("hosts", updatedHosts, { shouldDirty: true }); + // Use setHosts from context instead of form setValue for performance + setHosts(serverHosts, updatedHosts); showToast(t("location_applied_to_hosts", { count: result.updatedCount }), "success"); onSuccess(); @@ -840,12 +818,19 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro const isOrg = !!session.data?.user?.org?.id; const enablePerHostLocations = formMethods.watch("enablePerHostLocations"); - const hosts = formMethods.watch("hosts"); + // Use hosts from context for mutations, paginated query for data + const { setHosts, pendingChanges } = useHosts(); + + const { hosts, serverHosts } = usePaginatedAssignmentHosts({ + eventTypeId, + pendingChanges, + search: "", + }); const { hostDataMap, fullLocationOptions, containerRef, isLoading, isFetchingNextPage } = useHostLocationsData(eventTypeId, enablePerHostLocations, locationOptions); - const { handleToggle, handleLocationChange } = useHostLocationHandlers(formMethods, hosts, eventTypeId); - const { handleMassApply, isPending } = useMassApplyMutation(eventTypeId, formMethods, hosts, () => + const { handleToggle, handleLocationChange } = useHostLocationHandlers(formMethods, hosts, serverHosts, setHosts, eventTypeId); + const { handleMassApply, isPending } = useMassApplyMutation(eventTypeId, hosts, serverHosts, setHosts, () => setIsMassApplyDialogOpen(false) ); diff --git a/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx b/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx index 7b7f2b52d2fa4c..b53469bd208486 100644 --- a/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx +++ b/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx @@ -1,6 +1,16 @@ +import { useState, Suspense, useMemo, useEffect } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import type { z } from "zod"; + import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData"; import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext"; import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; +import { + SelectedCalendarsSettingsWebWrapper, + SelectedCalendarSettingsScope, + SelectedCalendarsSettingsWebWrapperSkeleton, +} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper"; import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone"; import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect"; import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector"; @@ -10,57 +20,50 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; +import { MultiplePrivateLinksController } from "@calcom/web/modules/event-types/components"; +import AddVerifiedEmail from "@calcom/web/modules/event-types/components/AddVerifiedEmail"; +import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink"; import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming"; import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming"; import type { - CheckboxClassNames, - EventTypeSetupProps, FormValues, - InputClassNames, + EventTypeSetupProps, SelectClassNames, + CheckboxClassNames, + InputClassNames, SettingsToggleClassNames, } from "@calcom/features/eventtypes/lib/types"; +import { FormBuilder } from "./FormBuilder"; import { BookerLayoutSelector } from "@calcom/web/modules/settings/components/BookerLayoutSelector"; import { - DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR, + DEFAULT_DARK_BRAND_COLOR, MAX_SEATS_PER_TIME_SLOT, } from "@calcom/lib/constants"; import { generateHashedLink } from "@calcom/lib/generateHashedLink"; import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; -import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils"; +import dayjs from "@calcom/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { Prisma } from "@calcom/prisma/client"; -import { CancellationReasonRequirement, SchedulingType } from "@calcom/prisma/enums"; -import type { EditableSchema, fieldSchema } from "@calcom/prisma/zod-utils"; +import { SchedulingType } from "@calcom/prisma/enums"; +import type { EditableSchema } from "@calcom/prisma/zod-utils"; +import type { fieldSchema } from "@calcom/prisma/zod-utils"; import type { RouterOutputs } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; import { Alert } from "@calcom/ui/components/alert"; import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { - CheckboxField, + SelectField, ColorPicker, + TextField, Label, - Select, - SelectField, - SettingsToggle, + CheckboxField, Switch, - TextField, + SettingsToggle, + Select, } from "@calcom/ui/components/form"; import { InfoIcon, PencilIcon } from "@coss/ui/icons"; -import { - SelectedCalendarSettingsScope, - SelectedCalendarsSettingsWebWrapper, - SelectedCalendarsSettingsWebWrapperSkeleton, -} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper"; -import { MultiplePrivateLinksController } from "@calcom/web/modules/event-types/components"; -import AddVerifiedEmail from "@calcom/web/modules/event-types/components/AddVerifiedEmail"; -import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink"; -import type { Dispatch, SetStateAction } from "react"; -import { Suspense, useEffect, useMemo, useState } from "react"; -import { Controller, useFormContext } from "react-hook-form"; -import type { z } from "zod"; import type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal"; import CustomEventTypeModal from "./CustomEventTypeModal"; @@ -68,7 +71,6 @@ import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmails import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting"; import type { DisableReschedulingCustomClassNames } from "./DisableReschedulingController"; import DisableReschedulingController from "./DisableReschedulingController"; -import { FormBuilder } from "./FormBuilder"; import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController"; import RequiresConfirmationController from "./RequiresConfirmationController"; @@ -560,13 +562,7 @@ export const EventAdvancedTab = ({ } ); - const userTimeZone = extractHostTimezone({ - userId: eventType.userId, - teamId: eventType.teamId, - hosts: eventType.hosts, - owner: eventType.owner, - team: eventType.team, - }); + const userTimeZone = eventType.owner?.timeZone ?? user?.timeZone ?? dayjs.tz.guess(); let verifiedSecondaryEmails = [ { @@ -674,43 +670,6 @@ export const EventAdvancedTab = ({ />
- {!isPlatform && ( - { - const cancellationReasonOptions = [ - { value: CancellationReasonRequirement.MANDATORY_BOTH, label: t("mandatory_for_both") }, - { - value: CancellationReasonRequirement.MANDATORY_HOST_ONLY, - label: t("mandatory_for_host_only"), - }, - { - value: CancellationReasonRequirement.MANDATORY_ATTENDEE_ONLY, - label: t("mandatory_for_attendee_only"), - }, - { value: CancellationReasonRequirement.OPTIONAL_BOTH, label: t("optional_for_both") }, - ]; - return ( -
-
-
-

{t("require_cancellation_reason")}

-

{t("require_cancellation_reason_description")}

-
- handleGroupNameChange(group.id, e.target.value)} + onChange={(e) => + handleGroupNameChange(group.id, e.target.value) + } className="border-none bg-transparent p-0 text-sm font-medium focus:outline-none focus:ring-0" placeholder={`Group ${groupNumber}`} /> @@ -542,7 +639,7 @@ const RoundRobinHosts = ({
- + {renderAddMembersWithSwitch(group.id)}
); })} @@ -559,43 +656,250 @@ type ChildrenEventTypesCustomClassNames = { childrenEventTypesList?: ChildrenEventTypeSelectCustomClassNames; }; +function mapSearchMemberToChildrenOption( + member: SearchTeamMember, + slug: string, + pendingString: string +): ChildrenEventType { + return { + slug, + hidden: false, + created: false, + owner: { + id: member.userId, + name: member.name ?? "", + email: member.email, + username: member.username ?? "", + membership: member.role, + eventTypeSlugs: [], + avatar: member.avatarUrl ?? "", + profile: null, + }, + value: `${member.userId}`, + label: `${member.name || member.email || ""}${ + !member.username ? ` (${pendingString})` : "" + }`, + }; +} + const ChildrenEventTypes = ({ - childrenEventTypeOptions, + teamId, + eventTypeId, + eventSlug, assignAllTeamMembers, setAssignAllTeamMembers, customClassNames, }: { - childrenEventTypeOptions: ReturnType[]; + teamId: number; + eventTypeId: number; + eventSlug: string; assignAllTeamMembers: boolean; setAssignAllTeamMembers: Dispatch>; customClassNames?: ChildrenEventTypesCustomClassNames; }) => { - const { setValue } = useFormContext(); + const { setValue, getValues } = useFormContext(); + const { t } = useLocale(); + + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); + + // Dropdown search for adding new children + const { + members, + fetchNextPage: fetchNextSearchPage, + hasNextPage: hasNextSearchPage, + isFetchingNextPage: isFetchingNextSearchPage, + } = useSearchTeamMembers({ + teamId, + search: debouncedSearch, + enabled: !assignAllTeamMembers, + }); + + // Search for assigned children list + const [assignedChildrenSearch, setAssignedChildrenSearch] = useState(""); + const debouncedAssignedChildrenSearch = useDebounce(assignedChildrenSearch, 300); + + // Paginated display of existing children + const watchedPendingChanges = useWatch({ name: "pendingChildrenChanges" }); + const pendingChanges = watchedPendingChanges ?? { + childrenToAdd: [], + childrenToRemove: [], + childrenToUpdate: [], + }; + + const { + children: paginatedChildren, + fetchNextPage: fetchNextChildrenPage, + hasNextPage: hasNextChildrenPage, + isFetchingNextPage: isFetchingNextChildrenPage, + isFetching: isFetchingAssignedChildren, + } = usePaginatedAssignmentChildren({ + eventTypeId, + pendingChanges, + search: debouncedAssignedChildrenSearch, + enabled: !assignAllTeamMembers, + }); + + // Convert paginated children to ChildrenEventType format for display + const displayChildren = useMemo( + (): ChildrenEventType[] => + paginatedChildren.map((child) => + assignmentChildToChildrenEventType(child, t("pending")) + ), + [paginatedChildren, t] + ); + + // Convert search members to dropdown options, excluding already-assigned children + const assignedOwnerIds = useMemo(() => { + const ids = new Set(paginatedChildren.map((c) => c.owner.id)); + for (const child of pendingChanges.childrenToAdd) { + ids.add(child.owner.id); + } + return ids; + }, [paginatedChildren, pendingChanges.childrenToAdd]); + + const childrenOptions = useMemo( + (): ChildrenEventType[] => + members + .filter((member) => !assignedOwnerIds.has(member.userId)) + .map((member) => + mapSearchMemberToChildrenOption(member, eventSlug, t("pending")) + ), + [members, eventSlug, t, assignedOwnerIds] + ); + + const handleChildrenChange = useCallback( + (newValue: readonly ChildrenEventType[]) => { + const currentChildren = displayChildren; + const newMap = new Map(newValue.map((c) => [c.owner.id, c])); + const currentMap = new Map(currentChildren.map((c) => [c.owner.id, c])); + + // Find newly added children (in newValue but not in current) + const added = newValue.filter((c) => !currentMap.has(c.owner.id)); + // Find removed children (in current but not in newValue) + const removed = currentChildren.filter((c) => !newMap.has(c.owner.id)); + // Find hidden changes (same children but hidden toggled) + const hiddenChanges: { userId: number; hidden: boolean }[] = []; + for (const [ownerId, newChild] of Array.from(newMap.entries())) { + const currentChild = currentMap.get(ownerId); + if (currentChild && currentChild.hidden !== newChild.hidden) { + hiddenChanges.push({ userId: ownerId, hidden: newChild.hidden }); + } + } + + const current = getValues("pendingChildrenChanges") ?? { + childrenToAdd: [], + childrenToRemove: [], + childrenToUpdate: [], + }; + + // Handle hidden changes on pending adds + let updatedAdds = [...current.childrenToAdd]; + const serverHiddenChanges: { userId: number; hidden: boolean }[] = []; + for (const change of hiddenChanges) { + const addIndex = updatedAdds.findIndex( + (c) => c.owner.id === change.userId + ); + if (addIndex >= 0) { + updatedAdds[addIndex] = { + ...updatedAdds[addIndex], + hidden: change.hidden, + }; + } else { + serverHiddenChanges.push(change); + } + } + + // Merge server hidden changes into childrenToUpdate + let updatedUpdates = [...current.childrenToUpdate]; + for (const change of serverHiddenChanges) { + const existingIndex = updatedUpdates.findIndex( + (u) => u.userId === change.userId + ); + if (existingIndex >= 0) { + updatedUpdates[existingIndex] = { + ...updatedUpdates[existingIndex], + hidden: change.hidden, + }; + } else { + updatedUpdates.push(change); + } + } + + // Remove pending adds that were removed + const removedIds = new Set(removed.map((c) => c.owner.id)); + updatedAdds = updatedAdds.filter((c) => !removedIds.has(c.owner.id)); + + // For removals: only add to childrenToRemove if not a pending add + const serverRemovals = removed + .filter( + (c) => !current.childrenToAdd.some((a) => a.owner.id === c.owner.id) + ) + .map((c) => c.owner.id); + + const updatedChanges = { + ...current, + childrenToAdd: [ + ...updatedAdds, + ...added.map((c) => ({ + ...c, + created: false, + })), + ], + childrenToRemove: [...current.childrenToRemove, ...serverRemovals], + childrenToUpdate: updatedUpdates, + }; + + setValue("pendingChildrenChanges", updatedChanges, { shouldDirty: true }); + }, + [displayChildren, getValues, setValue] + ); + return (
+ )} + >
setValue("children", childrenEventTypeOptions, { shouldDirty: true })} + onActive={() => + setValue( + "pendingChildrenChanges", + { + childrenToAdd: [], + childrenToRemove: [], + childrenToUpdate: [], + clearAllChildren: true, + }, + { shouldDirty: true } + ) + } /> {!assignAllTeamMembers ? ( - - name="children" - render={({ field: { onChange, value } }) => ( - - )} - /> + <> + { + if (options) handleChildrenChange(options); + }} + customClassNames={customClassNames?.childrenEventTypesList} + onSearchChange={setSearch} + onMenuScrollToBottom={() => { + if (hasNextSearchPage && !isFetchingNextSearchPage) + fetchNextSearchPage(); + }} + isLoadingMore={isFetchingNextSearchPage} + assignedSearchValue={assignedChildrenSearch} + onAssignedSearchChange={setAssignedChildrenSearch} + isSearchingAssigned={isFetchingAssignedChildren && !!debouncedAssignedChildrenSearch} + /> + ) : ( <> )} @@ -611,7 +915,6 @@ type HostsCustomClassNames = { const Hosts = ({ orgId, teamId, - teamMembers, assignAllTeamMembers, setAssignAllTeamMembers, customClassNames, @@ -620,7 +923,6 @@ const Hosts = ({ }: { orgId: number | null; teamId: number; - teamMembers: TeamMember[]; assignAllTeamMembers: boolean; setAssignAllTeamMembers: Dispatch>; customClassNames?: HostsCustomClassNames; @@ -629,39 +931,72 @@ const Hosts = ({ }) => { const { control, - setValue, - getValues, formState: { submitCount }, } = useFormContext(); + const { + addHost, + updateHost, + removeHost, + clearAllHosts, + setHosts, + pendingChanges, + } = useHosts(); + + const eventTypeId = useWatch({ control, name: "id" }); + + const [assignedSearch, setAssignedSearch] = useState(""); + const debouncedAssignedSearch = useDebounce(assignedSearch, 300); + + const { + hosts: paginatedHosts, + serverHosts, + serverHasFixedHosts, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching: isFetchingAssignedHosts, + } = usePaginatedAssignmentHosts({ + eventTypeId, + pendingChanges, + search: debouncedAssignedSearch, + }); + const schedulingType = useWatch({ control, name: "schedulingType", }); + + // Track scheduling type changes to reset hosts const initialValue = useRef<{ - hosts: FormValues["hosts"]; + pendingChanges: typeof pendingChanges; schedulingType: SchedulingType | null; submitCount: number; } | null>(null); useEffect(() => { // Handles init & out of date initial value after submission. - if (!initialValue.current || initialValue.current?.submitCount !== submitCount) { - initialValue.current = { hosts: getValues("hosts"), schedulingType, submitCount }; + if ( + !initialValue.current || + initialValue.current?.submitCount !== submitCount + ) { + initialValue.current = { pendingChanges, schedulingType, submitCount }; return; } - setValue( - "hosts", - initialValue.current.schedulingType === schedulingType ? initialValue.current.hosts : [], - { shouldDirty: true } - ); - }, [schedulingType, setValue, getValues, submitCount]); + if (initialValue.current.schedulingType !== schedulingType) { + // Scheduling type changed - clear all hosts + // Uses clearAllHosts which sets a flag for the backend to compute the delta + // This ensures ALL hosts are cleared, not just the paginated ones + clearAllHosts(); + } + }, [schedulingType, submitCount, clearAllHosts]); // To ensure existing host do not loose its scheduleId and groupId properties, whenever a new host of same type is added. // This is because the host is created from list option in CheckedHostField component. const updatedHosts = (changedHosts: Host[]) => { - const existingHosts = getValues("hosts"); return changedHosts.map((newValue) => { - const existingHost = existingHosts.find((host: Host) => host.userId === newValue.userId); + const existingHost = paginatedHosts.find( + (host: Host) => host.userId === newValue.userId + ); return existingHost ? { @@ -673,67 +1008,98 @@ const Hosts = ({ }); }; - return ( - - name="hosts" - render={({ field: { onChange, value } }) => { - const schedulingTypeRender = { - COLLECTIVE: hideFixedHostsForCollective ? ( - <> - ) : ( - { - onChange([...updatedHosts(changeValue)]); - }} - assignAllTeamMembers={assignAllTeamMembers} - setAssignAllTeamMembers={setAssignAllTeamMembers} - customClassNames={customClassNames?.fixedHosts} - /> - ), - ROUND_ROBIN: ( - <> - { - onChange([...value.filter((host: Host) => !host.isFixed), ...updatedHosts(changeValue)]); - }} - assignAllTeamMembers={assignAllTeamMembers} - setAssignAllTeamMembers={setAssignAllTeamMembers} - isRoundRobinEvent={true} - customClassNames={customClassNames?.fixedHosts} - /> - { - const hosts = [...value.filter((host: Host) => host.isFixed), ...updatedHosts(changeValue)]; - onChange(hosts); - }} - assignAllTeamMembers={assignAllTeamMembers} - setAssignAllTeamMembers={setAssignAllTeamMembers} - customClassNames={customClassNames?.roundRobinHosts} - isSegmentApplicable={isSegmentApplicable} - /> - - ), - MANAGED: <>, - }; - return schedulingType ? schedulingTypeRender[schedulingType] : <>; - }} - /> + // handleHostsChange computes delta between current hosts and new hosts + const handleHostsChange = useCallback( + (newHosts: Host[]) => { + setHosts(serverHosts, newHosts); + }, + [setHosts, serverHosts] ); + + const value = paginatedHosts; // hosts from paginated query (already merged with pending changes) + + const schedulingTypeRender = { + COLLECTIVE: hideFixedHostsForCollective ? ( + <> + ) : ( + { + handleHostsChange([...updatedHosts(changeValue)]); + }} + assignAllTeamMembers={assignAllTeamMembers} + setAssignAllTeamMembers={setAssignAllTeamMembers} + customClassNames={customClassNames?.fixedHosts} + serverHosts={serverHosts} + serverHasFixedHosts={serverHasFixedHosts} + pendingChanges={pendingChanges} + hasNextPageSelected={hasNextPage} + isFetchingNextPageSelected={isFetchingNextPage} + fetchNextPageSelected={fetchNextPage} + assignedSearch={assignedSearch} + onAssignedSearchChange={setAssignedSearch} + isSearchingAssigned={isFetchingAssignedHosts && !!debouncedAssignedSearch} + /> + ), + ROUND_ROBIN: ( + <> + { + handleHostsChange([ + ...value.filter((host: Host) => !host.isFixed), + ...updatedHosts(changeValue), + ]); + }} + assignAllTeamMembers={assignAllTeamMembers} + setAssignAllTeamMembers={setAssignAllTeamMembers} + isRoundRobinEvent={true} + customClassNames={customClassNames?.fixedHosts} + serverHosts={serverHosts} + serverHasFixedHosts={serverHasFixedHosts} + pendingChanges={pendingChanges} + hasNextPageSelected={hasNextPage} + isFetchingNextPageSelected={isFetchingNextPage} + fetchNextPageSelected={fetchNextPage} + assignedSearch={assignedSearch} + onAssignedSearchChange={setAssignedSearch} + isSearchingAssigned={isFetchingAssignedHosts && !!debouncedAssignedSearch} + /> + { + const newHosts = [ + ...value.filter((host: Host) => host.isFixed), + ...updatedHosts(changeValue), + ]; + handleHostsChange(newHosts); + }} + assignAllTeamMembers={assignAllTeamMembers} + setAssignAllTeamMembers={setAssignAllTeamMembers} + customClassNames={customClassNames?.roundRobinHosts} + isSegmentApplicable={isSegmentApplicable} + serverHosts={serverHosts} + hasNextPageSelected={hasNextPage} + isFetchingNextPageSelected={isFetchingNextPage} + fetchNextPageSelected={fetchNextPage} + assignedSearch={assignedSearch} + onAssignedSearchChange={setAssignedSearch} + isSearchingAssigned={isFetchingAssignedHosts && !!debouncedAssignedSearch} + /> + + ), + MANAGED: <>, + }; + + return schedulingType ? schedulingTypeRender[schedulingType] : <>; }; export const EventTeamAssignmentTab = ({ team, - teamMembers, eventType, customClassNames, orgId, @@ -758,24 +1124,8 @@ export const EventTeamAssignmentTab = ({ // description: t("round_robin_description"), }, ]; - const pendingMembers = (member: (typeof teamMembers)[number]) => - !!eventType.team?.parentId || !!member.username; - const teamMembersOptions = teamMembers - .filter(pendingMembers) - .map((member) => mapUserToValue(member, t("pending"))); - const childrenEventTypeOptions = teamMembers.filter(pendingMembers).map((member) => { - return mapMemberToChildrenOption( - { - ...member, - eventTypes: member.eventTypes.filter( - (et) => et !== eventType.slug || !eventType.children.some((c) => c.owner.id === member.id) - ), - }, - eventType.slug, - t("pending") - ); - }); - const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED; + const isManagedEventType = + eventType.schedulingType === SchedulingType.MANAGED; const { getValues, setValue, control } = useFormContext(); const [assignAllTeamMembers, setAssignAllTeamMembers] = useState( getValues("assignAllTeamMembers") ?? false @@ -788,7 +1138,10 @@ export const EventTeamAssignmentTab = ({ }; const handleSchedulingTypeChange = useCallback( - (schedulingType: SchedulingType | undefined, onChange: (value: SchedulingType | undefined) => void) => { + ( + schedulingType: SchedulingType | undefined, + onChange: (value: SchedulingType | undefined) => void + ) => { if (schedulingType) { onChange(schedulingType); resetRROptions(); @@ -797,7 +1150,10 @@ export const EventTeamAssignmentTab = ({ [setValue, setAssignAllTeamMembers] ); - const handleMaxLeadThresholdChange = (val: string, onChange: (value: number | null) => void) => { + const handleMaxLeadThresholdChange = ( + val: string, + onChange: (value: number | null) => void + ) => { if (val === "loadBalancing") { onChange(3); } else { @@ -823,26 +1179,38 @@ export const EventTeamAssignmentTab = ({ className={classNames( "border-subtle flex flex-col rounded-md", customClassNames?.assignmentType?.container - )}> + )} + >

+ )} + > {t("assignment_description")}

-