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:
co-authored by
Udit Takkar
parent
656d6dc195
commit
38e7eb03f9
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
+37
-34
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user