calcom / cal.com

Scheduling infrastructure for absolutely everyone.
https://cal.com
Other
31.27k stars 7.54k forks source link

[CAL-3921] Multiple duration events and OOO #15401

Open milospuac opened 3 months ago

milospuac commented 3 months ago

https://www.loom.com/share/ece5e29607d543c0855e995ddabb9d4c?sid=12552489-57e5-4406-8ee1-4a083794442a

From SyncLinear.com | CAL-3921

dosubot[bot] commented 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.