Open milospuac opened 3 months ago
To resolve the issue related to multiple duration events and Out of Office (OOO) settings as documented in issue CAL-3921, you can refer to the CreateOutOfOfficeEntryModal
component in the provided code. This component handles the creation of OOO entries, including setting the date range and other relevant details.
Here is the relevant part of the code that deals with setting the date range for OOO entries:
const CreateOutOfOfficeEntryModal = ({
openModal,
closeModal,
}: {
openModal: boolean;
closeModal: () => void;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [selectedReason, setSelectedReason] = useState<{ label: string; value: number } | null>(null);
const [profileRedirect, setProfileRedirect] = useState(false);
const [selectedMember, setSelectedMember] = useState<{ label: string; value: number | null } | null>(null);
const [dateRange] = useState<{ startDate: Date; endDate: Date }>({
startDate: dayjs().startOf("d").toDate(),
endDate: dayjs().add(1, "d").endOf("d").toDate(),
});
const { handleSubmit, setValue, control, register } = useForm<BookingRedirectForm>({
defaultValues: {
dateRange: {
startDate: dateRange.startDate,
endDate: dateRange.endDate,
},
offset: dayjs().utcOffset(),
toTeamUserId: null,
reasonId: 1,
},
});
const createOutOfOfficeEntry = trpc.viewer.outOfOfficeCreate.useMutation({
onSuccess: () => {
showToast(t("success_entry_created"), "success");
utils.viewer.outOfOfficeEntriesList.invalidate();
setProfileRedirect(false);
closeModal();
},
onError: (error) => {
showToast(t(error.message), "error");
},
});
const { data: outOfOfficeReasonList } = trpc.viewer.outOfOfficeReasonList.useQuery();
const reasonList = [
...(outOfOfficeReasonList || []).map((reason) => ({
label: `${reason.emoji} ${reason.userId === null ? t(reason.reason) : reason.reason}`,
value: reason.id,
})),
];
return (
<Dialog open={openModal}>
<DialogContent
onOpenAutoFocus={(event) => {
event.preventDefault();
}}>
<form
id="create-ooo-form"
className="h-full"
onSubmit={handleSubmit((data) => {
createOutOfOfficeEntry.mutate(data);
setValue("toTeamUserId", null);
setValue("notes", "");
setSelectedReason(null);
setSelectedMember(null);
})}>
<div className="px-1">
<DialogHeader title={t("create_an_out_of_office")} />
<div>
<p className="text-emphasis mb-1 block text-sm font-medium capitalize">{t("dates")}</p>
<div>
<Controller
name="dateRange"
control={control}
defaultValue={dateRange}
render={({ field: { onChange, value } }) => (
<DateRangePicker
dates={{ startDate: value.startDate, endDate: value.endDate }}
onDatesChange={(values) => {
onChange(values);
}}
/>
)}
/>
</div>
</div>
{/* Reason Select */}
<div className="mt-4 w-full">
<div className="">
<p className="text-emphasis block text-sm font-medium">{t("reason")}</p>
<Select
className="mb-0 mt-1 text-white"
name="reason"
data-testid="reason_select"
value={selectedReason}
placeholder={t("ooo_select_reason")}
options={reasonList}
onChange={(selectedOption) => {
if (selectedOption?.value) {
setSelectedReason(selectedOption);
setValue("reasonId", selectedOption?.value);
}
}}
/>
</div>
</div>
{/* Notes input */}
<div className="mt-4">
<p className="text-emphasis block text-sm font-medium">{t("notes")}</p>
<TextArea
data-testid="notes_input"
className="border-subtle mt-1 h-10 w-full rounded-lg border px-2"
placeholder={t("additional_notes")}
{...register("notes")}
onChange={(e) => {
setValue("notes", e?.target.value);
}}
/>
</div>
<div className="bg-muted my-4 rounded-xl p-5">
<div className="flex flex-row">
<Switch
disabled={!hasTeamPlan}
data-testid="profile-redirect-switch"
checked={profileRedirect}
id="profile-redirect-switch"
onCheckedChange={(state) => {
setProfileRedirect(state);
}}
label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")}
/>
{!hasTeamPlan && (
<div className="mx-2" data-testid="upgrade-team-badge">
<UpgradeTeamsBadge />
</div>
)}
</div>
{profileRedirect && (
<div className="mt-4">
<div className="h-16">
<p className="text-emphasis block text-sm font-medium">{t("team_member")}</p>
<Select
className="mt-1 h-4 text-white"
name="toTeamUsername"
data-testid="team_username_select"
value={selectedMember}
placeholder={t("select_team_member")}
isSearchable
options={memberListOptions}
onChange={(selectedOption) => {
if (selectedOption?.value) {
setSelectedMember(selectedOption);
setValue("toTeamUserId", selectedOption?.value);
}
}}
/>
</div>
</div>
)}
</div>
</div>
</form>
<DialogFooter showDivider noSticky>
<div className="flex">
<Button color="minimal" type="button" onClick={() => closeModal()} className="mr-1">
{t("cancel")}
</Button>
<Button
form="create-ooo-form"
color="primary"
type="submit"
disabled={createOutOfOfficeEntry.isPending}
data-testid="create-entry-ooo-redirect">
{t("create")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
This component allows users to set a date range for their OOO status, select a reason, add notes, and optionally redirect to a team member if they have a team plan. The DateRangePicker
component is used to select the start and end dates for the OOO period.
To address the issue, ensure that the DateRangePicker
is correctly handling the selection of multiple duration events and that the createOutOfOfficeEntry
mutation is properly processing the date range and other form data.
Additionally, you can refer to the outOfOfficeCreate
handler in packages/trpc/server/routers/loggedInViewer/outOfOffice.handler.ts
to understand how the backend processes the OOO entries:
export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
const { startDate, endDate } = input.dateRange;
if (!startDate || !endDate) {
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_and_end_date_required" });
}
const inputStartTime = dayjs(startDate).startOf("day");
const inputEndTime = dayjs(endDate).endOf("day");
const offset = dayjs(inputStartTime).utcOffset();
if (inputStartTime.isAfter(inputEndTime)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_before_end_date" });
}
if (
inputStartTime.isBefore(
dayjs()
.startOf("day")
.subtract(Math.abs(offset) * 60, "minute")
)
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_in_the_future" });
}
let toUserId;
if (input.toTeamUserId) {
const user = await prisma.user.findUnique({
where: {
id: input.toTeamUserId,
teams: {
some: {
team: {
members: {
some: {
userId: ctx.user.id,
accepted: true,
},
},
},
},
},
},
select: {
id: true,
},
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "user_not_found" });
}
toUserId = user?.id;
}
const outOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
where: {
AND: [
{ userId: ctx.user.id },
{
OR: [
{
start: {
lt: inputEndTime.toISOString(),
},
end: {
gt: inputStartTime.toISOString(),
},
},
{
start: {
gt: inputStartTime.toISOString(),
lt: inputEndTime.toISOString(),
},
},
{
end: {
gt: inputStartTime.toISOString(),
lt: inputEndTime.toISOString(),
},
},
],
},
],
},
});
if (outOfOfficeEntry) {
throw new TRPCError({ code: "CONFLICT", message: "out_of_office_entry_already_exists" });
}
if (!input.reasonId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "reason_id_required" });
}
const existingOutOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
select: {
userId: true,
toUserId: true,
},
where: {
userId: toUserId,
toUserId: ctx.user.id,
OR: [
{
AND: [
{ start: { lte: inputEndTime.toISOString() } },
{ end: { gte: inputStartTime.toISOString() } },
],
},
{
AND: [
{ start: { gte: inputStartTime.toISOString() } },
{ end: { lte: inputEndTime.toISOString() } },
],
},
],
},
});
if (existingOutOfOfficeEntry) {
throw new TRPCError({ code: "BAD_REQUEST", message: "booking_redirect_infinite_not_allowed" });
}
const startDateUtc = dayjs.utc(startDate).add(input.offset, "minute");
const endDateUtc = dayjs.utc(endDate).add(input.offset, "minute");
const createdRedirect = await prisma.outOfOfficeEntry.create({
data: {
uuid: uuidv4(),
start: startDateUtc.startOf("day").toISOString(),
end: endDateUtc.endOf("day").toISOString(),
notes: input.notes,
userId: ctx.user.id,
reasonId: input.reasonId,
toUserId: toUserId,
createdAt: new Date(),
updatedAt: new Date(),
},
});
if (toUserId) {
const userToNotify = await prisma.user.findFirst({
where: {
id: toUserId,
},
select: {
email: true,
},
});
const t = await getTranslation(ctx.user.locale ?? "en", "common");
const formattedStartDate = new Intl.DateTimeFormat("en-US").format(createdRedirect.start);
const formattedEndDate = new Intl.DateTimeFormat("en-US").format(createdRedirect.end);
if (userToNotify?.email) {
await sendBookingRedirectNotification({
language: t,
fromEmail: ctx.user.email,
toEmail: userToNotify.email,
toName: ctx.user.username || "",
dates: `${formattedStartDate} - ${formattedEndDate}`,
});
}
}
return {};
};
This handler ensures that the date range is valid, checks for overlapping OOO entries, and prevents infinite redirects. Make sure these validations are in place to handle multiple duration events and OOO settings correctly.
Additionally, you can refer to the end-to-end tests in apps/web/playwright/out-of-office.e2e.ts
to verify the functionality:
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Out of office", () => {
test.skip("User can create out of office entry", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
await user.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.getByTestId("add_entry_ooo").click();
await page.getByTestId("reason_select").click();
await page.getByTestId("select-option-4").click();
await page.getByTestId("notes_input").click();
await page.getByTestId("notes_input").fill("Demo notes");
await page.getByTestId("create-entry-ooo-redirect").click();
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
});
test.skip("User can configure booking redirect", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
const userTo = await users.create({ name: "userTwo" });
const team = await prisma.team.create({
data: {
name: "test-insights",
slug: `test-insights-${Date.now()}-${randomString(5)}}`,
},
});
await prisma.membership.createMany({
data: [
{
userId: user.id,
teamId: team.id,
accepted: true,
role: "ADMIN",
},
{
userId: userTo.id,
teamId: team.id,
accepted: true,
role: "ADMIN",
},
],
});
await user.apiLogin();
await page.goto(`/settings/my-account/out-of-office`);
await page.getByTestId("add_entry_ooo").click();
await page.getByTestId("reason_select").click();
await page.getByTestId("select-option-4").click();
await page.getByTestId("notes_input").click();
await page.getByTestId("notes_input").fill("Demo notes");
await page.getByTestId("profile-redirect-switch").click();
await page.getByTestId("team_username_select").click();
await page.locator("#react-select-3-input").fill("user");
await page.locator("#react-select-3-input").press("Enter");
await page.getByTestId("create-entry-ooo-redirect").click();
await expect(page.locator(`data-testid=table-redirect-${userTo.username}`)).toBeVisible();
});
test("Profile redirection", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
const userTo = await users.create({ name: "userTwo" });
const uuid = uuidv4();
await prisma.outOfOfficeEntry.create({
data: {
start: dayjs().startOf("day").toDate(),
end: dayjs().startOf("day").add(1, "w").toDate(),
uuid,
user: { connect: { id: user.id } },
toUser: { connect: { id: userTo.id } },
createdAt: new Date(),
reason: {
connect: {
id: 1,
},
},
},
});
await page.goto(`/${user.username}`);
const eventTypeLink = page.locator('[data-testid="event-type-link"]').first();
await eventTypeLink.click();
await expect(page.getByTestId("away-emoji")).toBeTruthy();
});
});
These tests ensure that users can create OOO entries, configure booking redirects, and verify profile redirection. Make sure these tests pass to confirm that the functionality is working as expected [1][2][3].
To continue talking to Dosu, mention @dosu.
https://www.loom.com/share/ece5e29607d543c0855e995ddabb9d4c?sid=12552489-57e5-4406-8ee1-4a083794442a
From SyncLinear.com | CAL-3921