fix: attribute assignment hosts (#18831)

* load correct users in query state for attribute rr

* add segment loading state

* skeleton

* fix query value

* update tests

* fix type

* use query by testId so no error is thrown

* ensure queryValue children has valid values before we trigger api key

* chore: remove lg

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
sean-brydon
2025-01-24 09:41:13 +00:00
committed by GitHub
co-authored by Udit Takkar
parent 656d6dc195
commit 38e7eb03f9
4 changed files with 170 additions and 57 deletions
+2 -2
View File
@@ -116,7 +116,7 @@ describe("Segment", () => {
await waitFor(() => {
// Query builder container should be present
expect(screen.getByTestId("query-builder-container")).toBeInTheDocument();
expect(screen.queryByText("loading")).not.toBeInTheDocument();
expect(screen.queryByTestId("segment_loading_state")).not.toBeInTheDocument();
});
});
@@ -128,7 +128,7 @@ describe("Segment", () => {
render(<Segment {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("loading")).toBeInTheDocument();
expect(screen.getByTestId("segment_loading_state")).toBeInTheDocument();
});
});
+41 -7
View File
@@ -105,14 +105,48 @@ function MatchingTeamMembers({
queryValue: AttributesQueryValue | null;
}) {
const { t } = useLocale();
const { data: matchingTeamMembersWithResult, isPending } =
trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery({
teamId,
attributesQueryValue: queryValue,
_enablePerf: true,
});
if (isPending) return <span>{t("loading")}</span>;
// Check if queryValue has valid children properties value
const hasValidValue = queryValue?.children1
? Object.values(queryValue.children1).some(
(child) => child.properties?.value?.[0] !== undefined && child.properties?.value?.[0] !== null
)
: false;
const { data: matchingTeamMembersWithResult, isPending } =
trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery(
{
teamId,
attributesQueryValue: queryValue,
_enablePerf: true,
},
{
enabled: hasValidValue,
}
);
if (isPending) {
return (
<div
className="border-subtle bg-muted mt-4 space-y-3 rounded-md border p-4"
data-testid="segment_loading_state">
<div className="text-emphasis flex items-center text-sm font-medium">
<div className="bg-subtle h-4 w-32 animate-pulse rounded" />
</div>
<ul className="divide-subtle divide-y">
{[...Array(3)].map((_, index) => (
<li key={index} className="flex items-center py-2">
<div className="flex flex-1 items-center space-x-2 text-sm">
<div className="bg-subtle h-4 w-24 animate-pulse rounded" />
<div className="bg-subtle h-4 w-32 animate-pulse rounded" />
</div>
</li>
))}
</ul>
</div>
);
}
if (!matchingTeamMembersWithResult) return <span>{t("something_went_wrong")}</span>;
const { result: matchingTeamMembers } = matchingTeamMembersWithResult;
if (!matchingTeamMembers || !queryValue) {
@@ -7,7 +7,8 @@ import { useState, useEffect, useRef, useMemo } from "react";
import type { Host, TeamMember } from "@calcom/features/eventtypes/lib/types";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import "@calcom/ui";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
import { trpc } from "@calcom/trpc";
import {
Avatar,
Button,
@@ -25,7 +26,7 @@ import {
} from "@calcom/ui";
type TeamMemberItemProps = {
member: TeamMember & { weight?: number };
member: Omit<TeamMember, "defaultScheduleId"> & { weight?: number };
onWeightChange: (memberId: string, weight: number) => void;
};
@@ -92,29 +93,93 @@ const TeamMemberItem = ({ member, onWeightChange }: TeamMemberItemProps) => {
);
};
interface UseTeamMembersWithSegmentProps {
initialTeamMembers: TeamMember[];
assignRRMembersUsingSegment: boolean;
teamId?: number;
queryValue?: AttributesQueryValue | null;
value: Host[];
}
const useTeamMembersWithSegment = ({
initialTeamMembers,
assignRRMembersUsingSegment,
teamId,
queryValue,
value,
}: UseTeamMembersWithSegmentProps) => {
const { data: matchingTeamMembersWithResult, isPending } =
trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery(
{
teamId: teamId || 0,
attributesQueryValue: queryValue as AttributesQueryValue,
_enablePerf: true,
},
{
enabled: assignRRMembersUsingSegment && !!queryValue && !!teamId,
}
);
const teamMembers = useMemo(() => {
if (assignRRMembersUsingSegment && matchingTeamMembersWithResult?.result) {
return matchingTeamMembersWithResult.result.map((member) => ({
value: member.id.toString(),
label: member.name || member.email,
email: member.email,
avatar: "", // Add avatar with fallback to empty string
}));
}
return initialTeamMembers;
}, [assignRRMembersUsingSegment, matchingTeamMembersWithResult, initialTeamMembers]);
const localWeightsInitialValues = useMemo(
() =>
teamMembers.reduce<Record<string, number>>((acc, member) => {
const memberInValue = value.find((host) => host.userId === parseInt(member.value, 10));
acc[member.value] = memberInValue?.weight ?? 100;
return acc;
}, {}),
[teamMembers, value]
);
return {
teamMembers,
localWeightsInitialValues,
isPending,
};
};
interface Props {
teamMembers: TeamMember[];
value: Host[];
onChange: (hosts: Host[]) => void;
assignAllTeamMembers: boolean;
assignRRMembersUsingSegment: boolean;
teamId?: number;
queryValue?: AttributesQueryValue | null;
}
export const EditWeightsForAllTeamMembers = ({
teamMembers,
teamMembers: initialTeamMembers,
value,
onChange,
assignAllTeamMembers,
assignRRMembersUsingSegment,
teamId,
queryValue,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useLocale();
const [searchQuery, setSearchQuery] = useState("");
const localWeightsInitialValues = teamMembers.reduce<Record<string, number>>((acc, member) => {
// When assignAllTeamMembers is false, only include members that exist in value array
// Find the member in the value array and use its weight if it exists
const memberInValue = value.find((host) => host.userId === parseInt(member.value, 10));
acc[member.value] = memberInValue?.weight ?? 100;
return acc;
}, {});
const { teamMembers, localWeightsInitialValues } = useTeamMembersWithSegment({
initialTeamMembers,
assignRRMembersUsingSegment,
teamId,
queryValue,
value,
});
const [localWeights, setLocalWeights] = useState<Record<string, number>>(localWeightsInitialValues);
const [uploadErrors, setUploadErrors] = useState<Array<{ email: string; error: string }>>([]);
const [isErrorsExpanded, setIsErrorsExpanded] = useState(true);
@@ -124,10 +189,21 @@ export const EditWeightsForAllTeamMembers = ({
};
const handleSave = () => {
const updatedValue = value.map((host) => ({
...host,
weight: localWeights[host.userId.toString()] ?? host.weight ?? 100,
}));
// Create a map of existing hosts for easy lookup
const existingHostsMap = new Map(value.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);
return {
...existingHost,
userId: parseInt(member.value, 10),
isFixed: existingHost?.isFixed ?? false,
priority: existingHost?.priority ?? 0,
weight: localWeights[member.value] ?? existingHost?.weight ?? 100,
};
});
onChange(updatedValue);
setIsOpen(false);
};
@@ -299,6 +299,10 @@ const RoundRobinHosts = ({
control,
name: "isRRWeightsEnabled",
});
const rrSegmentQueryValue = useWatch({
control,
name: "rrSegmentQueryValue",
});
return (
<div className={classNames("rounded-lg")}>
@@ -319,40 +323,39 @@ const RoundRobinHosts = ({
</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0 px-6 pt-4">
{!assignRRMembersUsingSegment && (
<>
<Controller<FormValues>
name="isRRWeightsEnabled"
render={({ field: { value: isRRWeightsEnabled, onChange } }) => (
<SettingsToggle
title={t("enable_weights")}
description={weightDescription}
checked={isRRWeightsEnabled}
switchContainerClassName={customClassNames?.enableWeights?.container}
labelClassName={customClassNames?.enableWeights?.label}
descriptionClassName={customClassNames?.enableWeights?.description}
onCheckedChange={(active) => {
onChange(active);
const rrHosts = getValues("hosts").filter((host) => !host.isFixed);
const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active));
setValue("hosts", sortedRRHosts);
}}>
{!assignRRMembersUsingSegment ? (
<EditWeightsForAllTeamMembers
teamMembers={teamMembers}
value={value}
onChange={(hosts) => {
const sortedRRHosts = hosts.sort((a, b) => sortHosts(a, b, true));
setValue("hosts", sortedRRHosts, { shouldDirty: true });
}}
assignAllTeamMembers={assignAllTeamMembers}
/>
) : null}
</SettingsToggle>
)}
/>
</>
)}
<>
<Controller<FormValues>
name="isRRWeightsEnabled"
render={({ field: { value: isRRWeightsEnabled, onChange } }) => (
<SettingsToggle
title={t("enable_weights")}
description={weightDescription}
checked={isRRWeightsEnabled}
switchContainerClassName={customClassNames?.enableWeights?.container}
labelClassName={customClassNames?.enableWeights?.label}
descriptionClassName={customClassNames?.enableWeights?.description}
onCheckedChange={(active) => {
onChange(active);
const rrHosts = getValues("hosts").filter((host) => !host.isFixed);
const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active));
setValue("hosts", sortedRRHosts);
}}>
<EditWeightsForAllTeamMembers
teamMembers={teamMembers}
value={value}
onChange={(hosts) => {
const sortedRRHosts = hosts.sort((a, b) => sortHosts(a, b, true));
setValue("hosts", sortedRRHosts, { shouldDirty: true });
}}
assignAllTeamMembers={assignAllTeamMembers}
assignRRMembersUsingSegment={assignRRMembersUsingSegment}
teamId={teamId}
queryValue={rrSegmentQueryValue}
/>
</SettingsToggle>
)}
/>
</>
<AddMembersWithSwitch
teamId={teamId}
teamMembers={teamMembers}