feat: Implement editable OOO events feature (#15932)

* feat: Implement edit ooo feature

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor button text and gap between buttons

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix entry create and edit edge cases

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Change "edit ooo event" modal title

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Add tooltips to entry eidt and delete buttons

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Reset form on form close event

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor reset-form function

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Rename create or edit OOO as suggested

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor component and function names

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Improve ooo events email notifications

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix email templates

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add e2e test for ooo event edit functionality

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Create separate test for edit ooo event

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Remove extra user from redirect user configuration test

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: code refactor

* remove log

* chore: Update ooo-input-schema uuid data type

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Address feedbacks

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: code splitting and refactor

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-08-29 17:28:35 +00:00
1 parent 54f7c8328d
commit ad5d645347
10 files changed
+714 -380

No files matched your search

@@ -1,35 +1,12 @@
import { Trans } from "next-i18next";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Controller, useForm, useFormState } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { CreateOrEditOutOfOfficeEntryModal } from "@calcom/features/settings/CreateOrEditOutOfOfficeModal";
import { OutOfOfficeEntriesList } from "@calcom/features/settings/OutOfOfficeEntriesList";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import {
Button,
DateRangePicker,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
EmptyScreen,
Icon,
Meta,
Select,
showToast,
SkeletonText,
Switch,
TableBody,
TableCell,
TableNew,
TableRow,
TextArea,
UpgradeTeamsBadge,
} from "@calcom/ui";
import { Button, Icon, Meta, SkeletonText } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -39,328 +16,7 @@ export type BookingRedirectForm = {
toTeamUserId: number | null;
reasonId: number;
notes?: string;
};
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().utc().startOf("d").toDate(),
endDate: dayjs().utc().add(1, "d").endOf("d").toDate(),
});
const { hasTeamPlan } = useHasTeamPlan();
const { data: listMembers } = trpc.viewer.teams.listMembers.useQuery({});
const me = useMeQuery();
const memberListOptions: {
value: number | null;
label: string;
}[] =
listMembers
?.filter((member) => me?.data?.id !== member.id)
.map((member) => ({
value: member.id || null,
label: member.name || "",
})) || [];
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>
);
};
const OutOfOfficeEntriesList = () => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { data, isPending } = trpc.viewer.outOfOfficeEntriesList.useQuery();
const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
onSuccess: () => {
showToast(t("success_deleted_entry_out_of_office"), "success");
utils.viewer.outOfOfficeEntriesList.invalidate();
useFormState;
},
onError: () => {
showToast(`An error ocurred`, "error");
},
});
if (data === null || data?.length === 0 || (data === undefined && !isPending))
return (
<EmptyScreen
className="mt-6"
headline={t("ooo_empty_title")}
description={t("ooo_empty_description")}
customIcon={
<div className="mt-4 h-[102px]">
<div className="flex h-full flex-col items-center justify-center p-2 md:mt-0 md:p-0">
<div className="relative">
<div className="dark:bg-darkgray-50 absolute -left-3 -top-3 -z-20 h-[70px] w-[70px] -rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-40 dark:opacity-80">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 absolute -top-3 left-3 -z-10 h-[70px] w-[70px] rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-60 dark:opacity-90">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 relative z-0 flex h-[70px] w-[70px] items-center justify-center rounded-3xl border-2 border-[#e5e7eb] bg-white">
<Icon name="clock" size={28} />
<div className="dark:bg-darkgray-50 absolute right-4 top-5 h-[12px] w-[12px] rotate-[56deg] bg-white text-lg font-bold" />
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold">z</span>
</div>
</div>
</div>
</div>
}
/>
);
return (
<div className="border-subtle mt-6 rounded-lg border">
<TableNew className="border-0">
<TableBody>
{data?.map((item) => (
<TableRow key={item.id} data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
<TableCell className="flex flex-row justify-between p-4">
<div className="flex flex-row items-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-50">
{item?.reason?.emoji || "🏝️"}
</div>
<div className="ml-2 flex flex-col">
<p className="px-2 font-bold">
{dayjs.utc(item.start).format("ll")} - {dayjs.utc(item.end).format("ll")}
</p>
<p className="px-2">
{item.toUser?.username ? (
<Trans
i18nKey="ooo_forwarding_to"
values={{
username: item.toUser?.username,
}}
components={{
span: <span className="text-subtle font-bold" />,
}}
/>
) : (
<>{t("ooo_not_forwarding")}</>
)}
</p>
{item.notes && (
<p className="px-2">
<span className="text-subtle">{t("notes")}: </span>
{item.notes}
</p>
)}
</div>
</div>
<Button
className="self-center rounded-lg border"
type="button"
color="minimal"
variant="icon"
disabled={deleteOutOfOfficeEntryMutation.isPending}
StartIcon="trash-2"
onClick={() => {
deleteOutOfOfficeEntryMutation.mutate({ outOfOfficeUid: item.uuid });
}}
/>
</TableCell>
</TableRow>
))}
{isPending && (
<>
{new Array(2).fill(0).map((_, index) => (
<TableRow key={index}>
<TableCell>
<SkeletonText className="h-12 w-full" />
</TableCell>
</TableRow>
))}
</>
)}
{!isPending && (data === undefined || data.length === 0) && (
<TableRow>
<TableCell colSpan={6} className="text-center">
<p className="text-subtle text-sm">{t("no_redirects_found")}</p>
</TableCell>
</TableRow>
)}
</TableBody>
</TableNew>
</div>
);
uuid?: string | null;
};
const OutOfOfficePage = () => {
@@ -375,6 +31,13 @@ const OutOfOfficePage = () => {
}, [openModalOnStart]);
const [openModal, setOpenModal] = useState(false);
const [currentlyEditingOutOfOfficeEntry, setCurrentlyEditingOutOfOfficeEntry] =
useState<BookingRedirectForm | null>(null);
const editOutOfOfficeEntry = (entry: BookingRedirectForm) => {
setCurrentlyEditingOutOfOfficeEntry(entry);
setOpenModal(true);
};
const { isPending } = trpc.viewer.outOfOfficeReasonList.useQuery();
return (
@@ -397,8 +60,17 @@ const OutOfOfficePage = () => {
)
}
/>
<CreateOutOfOfficeEntryModal openModal={openModal} closeModal={() => setOpenModal(false)} />
<OutOfOfficeEntriesList />
{openModal && (
<CreateOrEditOutOfOfficeEntryModal
openModal={openModal}
closeModal={() => {
setOpenModal(false);
setCurrentlyEditingOutOfOfficeEntry(null);
}}
currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
/>
)}
<OutOfOfficeEntriesList editOutOfOfficeEntry={editOutOfOfficeEntry} />
</>
);
};
+93 -2
View File
@@ -17,15 +17,17 @@ test.describe("Out of office", () => {
await user.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("networkidle");
await page.getByTestId("add_entry_ooo").click();
await page.waitForLoadState("networkidle");
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 page.getByTestId("create-or-edit-entry-ooo-redirect").click();
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
});
@@ -62,8 +64,10 @@ test.describe("Out of office", () => {
await user.apiLogin();
await page.goto(`/settings/my-account/out-of-office`);
await page.waitForLoadState("networkidle");
await page.getByTestId("add_entry_ooo").click();
await page.waitForLoadState("networkidle");
await page.getByTestId("reason_select").click();
await page.getByTestId("select-option-4").click();
@@ -79,12 +83,98 @@ test.describe("Out of office", () => {
await page.locator("#react-select-3-input").press("Enter");
// send request
await page.getByTestId("create-entry-ooo-redirect").click();
await page.getByTestId("create-or-edit-entry-ooo-redirect").click();
// expect table-redirect-toUserId to be visible
await expect(page.locator(`data-testid=table-redirect-${userTo.username}`)).toBeVisible();
});
test("User can edit out of office entry", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
const userTo = await users.create({ name: "userTwo" });
const userToSecond = await users.create({ name: "userThree" });
const team = await prisma.team.create({
data: {
name: "test-insights",
slug: `test-insights-${Date.now()}-${randomString(5)}}`,
},
});
// create memberships
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",
},
{
userId: userToSecond.id,
teamId: team.id,
accepted: true,
role: "ADMIN",
},
],
});
// Skip creating the ooo entry through front-end as we can assume that it has already been tested above.
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 user.apiLogin();
await page.goto(`/settings/my-account/out-of-office`);
await page.waitForLoadState("networkidle");
// expect table-redirect-toUserId to be visible
await expect(page.locator(`data-testid=table-redirect-${userTo.username}`)).toBeVisible();
// Open the edit modal and change redirect user and note.
await page.getByTestId(`ooo-edit-${userTo.username}`).click();
await page.getByTestId("notes_input").click();
await page.getByTestId("notes_input").fill("Changed notes");
await page.getByTestId("team_username_select").click();
await page.locator("#react-select-3-input").fill("userThree");
await page.locator("#react-select-3-input").press("Enter");
// send request
await page.getByTestId("create-or-edit-entry-ooo-redirect").click();
// expect entry with new username exist.
await expect(page.locator(`data-testid=table-redirect-${userToSecond.username}`)).toBeVisible();
// expect new note to be present.
await expect(page.locator(`data-testid=ooo-entry-note-${userToSecond.username}`)).toBeVisible();
await expect(page.locator(`data-testid=ooo-entry-note-${userToSecond.username}`)).toContainText(
"Changed notes"
);
});
test("Profile redirection", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
const userTo = await users.create({ name: "userTwo" });
@@ -106,6 +196,7 @@ test.describe("Out of office", () => {
});
await page.goto(`/${user.username}`);
await page.waitForLoadState("networkidle");
const eventTypeLink = page.locator('[data-testid="event-type-link"]').first();
await eventTypeLink.click();
@@ -2348,7 +2348,13 @@
"success_entry_created": "Successfully created a new entry",
"booking_redirect_email_subject": "Booking redirect notification",
"booking_redirect_email_title": "Booking Redirect Notification",
"booking_redirect_email_description": "You have received a booking redirection from {{toName}} so their profile links will be redirect to yours for the time interval: ",
"booking_redirect_email_description": "You have received a booking redirection from {{eventOwner}} so their profile links will be redirect to yours for the time interval: \"{{dates}}\"",
"booking_redirect_updated_email_subject": "Booking redirect edit notification",
"booking_redirect_updated_email_title": "Booking Redirect Edit Notification",
"booking_redirect_updated_email_description": "Your booking redirection from {{eventOwner}} for interval \"{{oldDates}}\" have been udpated. New time interval for the redirection is: \"{{dates}}\"",
"booking_redirect_cancelled_email_subject": "Booking redirect cancel notification",
"booking_redirect_cancelled_email_title": "Booking Redirect Cancel Notification",
"booking_redirect_cancelled_email_description": "Your booking redirection from {{eventOwner}}, for time interval \"{{dates}}\" have been cancelled.",
"success_accept_booking_redirect": "You have accepted this booking redirect request.",
"success_reject_booking_redirect": "You have rejected this booking redirect request.",
"copy_link_booking_redirect_request": "Copy link to share request",
@@ -2359,6 +2365,7 @@
"redirect_team_disabled": "Provide a link to a team member when OOO (Team plan required)",
"out_of_office_unavailable_list": "Out of office unavailability list",
"success_deleted_entry_out_of_office": "Successfully deleted entry",
"success_edited_entry_out_of_office": "Successfully edited entry",
"temporarily_out_of_office": "Temporarily Out-Of-Office?",
"add_a_redirect": "Add a redirect",
"create_entry": "Create entry",
@@ -2486,6 +2493,7 @@
"ooo_create_entry_modal": "Go Out of Office",
"ooo_select_reason": "Select reason",
"create_an_out_of_office": "Go Out of Office",
"edit_an_out_of_office": "Edit Out of Office Entry",
"submit_feedback": "Submit Feedback",
"host_no_show": "Your host didn't show up",
"no_show_description": "You can reschedule another meeting with them",
@@ -6,8 +6,20 @@ export const BookingRedirectEmailNotification = (
) => {
return (
<BaseEmailHtml
subject={props.language("booking_redirect_email_subject")}
title={props.language("booking_redirect_email_title")}>
subject={props.language(
{
add: "booking_redirect_email_subject",
update: "booking_redirect_updated_email_subject",
cancel: "booking_redirect_cancelled_email_subject",
}[props.action]
)}
title={props.language(
{
add: "booking_redirect_email_title",
update: "booking_redirect_updated_email_title",
cancel: "booking_redirect_cancelled_email_title",
}[props.action]
)}>
<p
style={{
color: "black",
@@ -15,10 +27,18 @@ export const BookingRedirectEmailNotification = (
lineHeight: "24px",
fontWeight: "400",
}}>
{props.language("booking_redirect_email_description", {
toName: props.toName,
})}
{props.dates}
{props.language(
{
add: "booking_redirect_email_description",
update: "booking_redirect_updated_email_description",
cancel: "booking_redirect_cancelled_email_description",
}[props.action],
{
eventOwner: props.eventOwner,
dates: props.dates,
oldDates: props.oldDates ?? "",
}
)}
<br />
<div
style={{
@@ -8,9 +8,12 @@ import BaseEmail from "./_base-email";
export interface IBookingRedirect {
language: TFunction;
fromEmail: string;
eventOwner: string;
toEmail: string;
toName: string;
oldDates?: string;
dates: string;
action: "add" | "update" | "cancel";
}
export default class BookingRedirectNotification extends BaseEmail {
@@ -26,7 +29,13 @@ export default class BookingRedirectNotification extends BaseEmail {
return {
to: `${this.bookingRedirect.toName} <${this.bookingRedirect.toEmail}>`,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
subject: this.bookingRedirect.language("booking_redirect_email_subject"),
subject: this.bookingRedirect.language(
{
add: "booking_redirect_email_subject",
update: "booking_redirect_updated_email_subject",
cancel: "booking_redirect_cancelled_email_subject",
}[this.bookingRedirect.action]
),
html: await renderEmail("BookingRedirectEmailNotification", {
...this.bookingRedirect,
}),
@@ -0,0 +1,243 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import {
Button,
DateRangePicker,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
Select,
showToast,
Switch,
TextArea,
UpgradeTeamsBadge,
} from "@calcom/ui";
import type { BookingRedirectForm } from "@calcom/web/pages/settings/my-account/out-of-office";
export const CreateOrEditOutOfOfficeEntryModal = ({
openModal,
closeModal,
currentlyEditingOutOfOfficeEntry,
}: {
openModal: boolean;
closeModal: () => void;
currentlyEditingOutOfOfficeEntry: BookingRedirectForm | null;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { data: listMembers } = trpc.viewer.teams.listMembers.useQuery({});
const me = useMeQuery();
const memberListOptions: {
value: number;
label: string;
}[] =
listMembers
?.filter((member) => me?.data?.id !== member.id)
.map((member) => ({
value: member.id,
label: member.name || "",
})) || [];
type Option = { value: number; label: string };
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,
}));
const [profileRedirect, setProfileRedirect] = useState(!!currentlyEditingOutOfOfficeEntry?.toTeamUserId);
const { hasTeamPlan } = useHasTeamPlan();
const { handleSubmit, setValue, control, register } = useForm<BookingRedirectForm>({
defaultValues: currentlyEditingOutOfOfficeEntry
? currentlyEditingOutOfOfficeEntry
: {
dateRange: {
startDate: dayjs().utc().startOf("d").toDate(),
endDate: dayjs().utc().add(1, "d").endOf("d").toDate(),
},
offset: dayjs().utcOffset(),
toTeamUserId: null,
reasonId: 1,
},
});
const createOrEditOutOfOfficeEntry = trpc.viewer.outOfOfficeCreateOrUpdate.useMutation({
onSuccess: () => {
showToast(
currentlyEditingOutOfOfficeEntry
? t("success_edited_entry_out_of_office")
: t("success_entry_created"),
"success"
);
utils.viewer.outOfOfficeEntriesList.invalidate();
closeModal();
},
onError: (error) => {
showToast(t(error.message), "error");
},
});
return (
<Dialog open={openModal}>
<DialogContent
onOpenAutoFocus={(event) => {
event.preventDefault();
}}>
<form
id="create-or-edit-ooo-form"
className="h-full"
onSubmit={handleSubmit((data) => {
createOrEditOutOfOfficeEntry.mutate(data);
})}>
<div className="px-1">
<DialogHeader
title={
currentlyEditingOutOfOfficeEntry ? t("edit_an_out_of_office") : 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}
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>
<Controller
control={control}
name="reasonId"
render={({ field: { onChange, value } }) => (
<Select<Option>
className="mb-0 mt-1 text-white"
name="reason"
data-testid="reason_select"
value={reasonList.find((reason) => reason.value === value)}
placeholder={t("ooo_select_reason")}
options={reasonList}
onChange={(selectedOption) => {
if (selectedOption?.value) {
onChange(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);
if (!state) {
setValue("toTeamUserId", null);
}
}}
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>
<Controller
control={control}
name="toTeamUserId"
render={({ field: { onChange, value } }) => (
<Select<Option>
name="toTeamUsername"
data-testid="team_username_select"
value={memberListOptions.find((member) => member.value === value)}
placeholder={t("select_team_member")}
isSearchable
options={memberListOptions}
onChange={(selectedOption) => {
if (selectedOption?.value) {
onChange(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-or-edit-ooo-form"
color="primary"
type="submit"
disabled={createOrEditOutOfOfficeEntry.isPending}
data-testid="create-or-edit-entry-ooo-redirect">
{currentlyEditingOutOfOfficeEntry ? t("save") : t("create")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,173 @@
import { Trans } from "next-i18next";
import { useFormState } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Button,
EmptyScreen,
Icon,
showToast,
SkeletonText,
TableBody,
TableCell,
TableNew,
TableRow,
Tooltip,
} from "@calcom/ui";
import type { BookingRedirectForm } from "@calcom/web/pages/settings/my-account/out-of-office";
export const OutOfOfficeEntriesList = ({
editOutOfOfficeEntry,
}: {
editOutOfOfficeEntry: (entry: BookingRedirectForm) => void;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { data, isPending } = trpc.viewer.outOfOfficeEntriesList.useQuery();
const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
onSuccess: () => {
showToast(t("success_deleted_entry_out_of_office"), "success");
utils.viewer.outOfOfficeEntriesList.invalidate();
useFormState;
},
onError: () => {
showToast(`An error ocurred`, "error");
},
});
if (data === null || data?.length === 0 || (data === undefined && !isPending))
return (
<EmptyScreen
className="mt-6"
headline={t("ooo_empty_title")}
description={t("ooo_empty_description")}
customIcon={
<div className="mt-4 h-[102px]">
<div className="flex h-full flex-col items-center justify-center p-2 md:mt-0 md:p-0">
<div className="relative">
<div className="dark:bg-darkgray-50 absolute -left-3 -top-3 -z-20 h-[70px] w-[70px] -rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-40 dark:opacity-80">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 absolute -top-3 left-3 -z-10 h-[70px] w-[70px] rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-60 dark:opacity-90">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 relative z-0 flex h-[70px] w-[70px] items-center justify-center rounded-3xl border-2 border-[#e5e7eb] bg-white">
<Icon name="clock" size={28} />
<div className="dark:bg-darkgray-50 absolute right-4 top-5 h-[12px] w-[12px] rotate-[56deg] bg-white text-lg font-bold" />
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold">z</span>
</div>
</div>
</div>
</div>
}
/>
);
return (
<div className="border-subtle mt-6 rounded-lg border">
<TableNew className="border-0">
<TableBody>
{data?.map((item) => (
<TableRow key={item.id} data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
<TableCell className="flex flex-row justify-between p-4">
<div className="flex flex-row items-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-50">
{item?.reason?.emoji || "🏝️"}
</div>
<div className="ml-2 flex flex-col">
<p className="px-2 font-bold">
{dayjs.utc(item.start).format("ll")} - {dayjs.utc(item.end).format("ll")}
</p>
<p className="px-2">
{item.toUser?.username ? (
<Trans
i18nKey="ooo_forwarding_to"
values={{
username: item.toUser?.username,
}}
components={{
span: <span className="text-subtle font-bold" />,
}}
/>
) : (
<>{t("ooo_not_forwarding")}</>
)}
</p>
{item.notes && (
<p className="px-2">
<span className="text-subtle">{t("notes")}: </span>
<span data-testid={`ooo-entry-note-${item.toUser?.username || "n-a"}`}>
{item.notes}
</span>
</p>
)}
</div>
</div>
<div className="flex flex-row items-center gap-x-2">
<Tooltip content={t("edit")}>
<Button
className="self-center rounded-lg border"
type="button"
color="minimal"
variant="icon"
data-testid={`ooo-edit-${item.toUser?.username || "n-a"}`}
StartIcon="pencil"
onClick={() => {
const outOfOfficeEntryData: BookingRedirectForm = {
uuid: item.uuid,
dateRange: {
startDate: item.start,
endDate: dayjs(item.end).subtract(1, "d").toDate(),
},
offset: dayjs().utcOffset(),
toTeamUserId: item.toUserId,
reasonId: item.reason?.id ?? 1,
notes: item.notes ?? undefined,
};
editOutOfOfficeEntry(outOfOfficeEntryData);
}}
/>
</Tooltip>
<Tooltip content={t("delete")}>
<Button
className="self-center rounded-lg border"
type="button"
color="minimal"
variant="icon"
disabled={deleteOutOfOfficeEntryMutation.isPending}
StartIcon="trash-2"
onClick={() => {
deleteOutOfOfficeEntryMutation.mutate({ outOfOfficeUid: item.uuid });
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
{isPending && (
<>
{new Array(2).fill(0).map((_, index) => (
<TableRow key={index}>
<TableCell>
<SkeletonText className="h-12 w-full" />
</TableCell>
</TableRow>
))}
</>
)}
{!isPending && (data === undefined || data.length === 0) && (
<TableRow>
<TableCell colSpan={6} className="text-center">
<p className="text-subtle text-sm">{t("no_redirects_found")}</p>
</TableCell>
</TableRow>
)}
</TableBody>
</TableNew>
</div>
);
};
@@ -53,7 +53,7 @@ type AppsRouterHandlerCache = {
teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery;
getUserTopBanners?: typeof import("./getUserTopBanners.handler").getUserTopBannersHandler;
connectAndJoin?: typeof import("./connectAndJoin.handler").Handler;
outOfOfficeCreate?: typeof import("./outOfOffice.handler").outOfOfficeCreate;
outOfOfficeCreateOrUpdate?: typeof import("./outOfOffice.handler").outOfOfficeCreateOrUpdate;
outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList;
outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete;
addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler;
@@ -417,18 +417,22 @@ export const loggedInViewerRouter = router({
return UNSTABLE_HANDLER_CACHE.connectAndJoin({ ctx, input });
}),
outOfOfficeCreate: authedProcedure.input(ZOutOfOfficeInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreate) {
UNSTABLE_HANDLER_CACHE.outOfOfficeCreate = (await import("./outOfOffice.handler")).outOfOfficeCreate;
}
outOfOfficeCreateOrUpdate: authedProcedure
.input(ZOutOfOfficeInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate) {
UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate = (
await import("./outOfOffice.handler")
).outOfOfficeCreateOrUpdate;
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreate) {
throw new Error("Failed to load handler");
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.outOfOfficeCreate({ ctx, input });
}),
return UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate({ ctx, input });
}),
outOfOfficeEntriesList: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList) {
UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList = (
@@ -17,7 +17,7 @@ type TBookingRedirect = {
input: TOutOfOfficeInputSchema;
};
export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
export const outOfOfficeCreateOrUpdate = 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" });
@@ -79,6 +79,11 @@ export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
where: {
AND: [
{ userId: ctx.user.id },
{
uuid: {
not: input.uuid ?? "",
},
},
{
OR: [
{
@@ -154,8 +159,28 @@ export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
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: {
// Get the existing redirected user from existing out of office entry to send that user appropriate email.
const previousOutOfOfficeEntry = await prisma.outOfOfficeEntry.findUnique({
where: {
uuid: input.uuid ?? "",
},
select: {
start: true,
end: true,
toUser: {
select: {
email: true,
username: true,
},
},
},
});
const createdOrUpdatedOutOfOffice = await prisma.outOfOfficeEntry.upsert({
where: {
uuid: input.uuid ?? "",
},
create: {
uuid: uuidv4(),
start: startDateUtc.startOf("day").toISOString(),
end: endDateUtc.endOf("day").toISOString(),
@@ -166,6 +191,14 @@ export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
createdAt: new Date(),
updatedAt: new Date(),
},
update: {
start: startDateUtc.startOf("day").toISOString(),
end: endDateUtc.endOf("day").toISOString(),
notes: input.notes,
userId: ctx.user.id,
reasonId: input.reasonId,
toUserId: toUserId ? toUserId : null,
},
});
if (toUserId) {
@@ -176,20 +209,70 @@ export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
},
select: {
email: true,
username: 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) {
const formattedStartDate = new Intl.DateTimeFormat("en-US").format(createdOrUpdatedOutOfOffice.start);
const formattedEndDate = new Intl.DateTimeFormat("en-US").format(createdOrUpdatedOutOfOffice.end);
const existingFormattedStartDate = previousOutOfOfficeEntry
? new Intl.DateTimeFormat("en-US").format(previousOutOfOfficeEntry.start)
: "";
const existingFormattedEndDate = previousOutOfOfficeEntry
? new Intl.DateTimeFormat("en-US").format(previousOutOfOfficeEntry.end)
: "";
const existingRedirectedUser = previousOutOfOfficeEntry?.toUser
? previousOutOfOfficeEntry.toUser
: undefined;
// Send cancel email to the old redirect user if it is not same as the current redirect user.
if (existingRedirectedUser && existingRedirectedUser?.email !== userToNotify?.email) {
await sendBookingRedirectNotification({
language: t,
fromEmail: ctx.user.email,
toEmail: userToNotify.email,
toName: ctx.user.username || "",
dates: `${formattedStartDate} - ${formattedEndDate}`,
eventOwner: ctx.user.username || ctx.user.email,
toEmail: existingRedirectedUser.email,
toName: existingRedirectedUser.username || "",
dates: `${existingFormattedStartDate} - ${existingFormattedEndDate}`,
action: "cancel",
});
}
if (userToNotify?.email) {
// If new redirect user exists and it is same as the old redirect user, then send update email.
if (
existingRedirectedUser &&
existingRedirectedUser.email === userToNotify.email &&
(formattedStartDate !== existingFormattedStartDate || formattedEndDate !== existingFormattedEndDate)
) {
await sendBookingRedirectNotification({
language: t,
fromEmail: ctx.user.email,
eventOwner: ctx.user.username || ctx.user.email,
toEmail: userToNotify.email,
toName: userToNotify.username || "",
oldDates: `${existingFormattedStartDate} - ${existingFormattedEndDate}`,
dates: `${formattedStartDate} - ${formattedEndDate}`,
action: "update",
});
// If new redirect user exists and the previous redirect user didn't existed or the previous redirect user is not same as the new user, then send add email.
} else if (
!existingRedirectedUser ||
(existingRedirectedUser && existingRedirectedUser.email !== userToNotify.email)
) {
await sendBookingRedirectNotification({
language: t,
fromEmail: ctx.user.email,
eventOwner: ctx.user.username || ctx.user.email,
toEmail: userToNotify.email,
toName: userToNotify.username || "",
dates: `${formattedStartDate} - ${formattedEndDate}`,
action: "add",
});
}
}
}
return {};
@@ -213,12 +296,42 @@ export const outOfOfficeEntryDelete = async ({ ctx, input }: TBookingRedirectDel
/** Validate outOfOfficeEntry belongs to the user deleting it */
userId: ctx.user.id,
},
select: {
start: true,
end: true,
toUser: {
select: {
email: true,
username: true,
},
},
},
});
if (!deletedOutOfOfficeEntry) {
throw new TRPCError({ code: "NOT_FOUND", message: "booking_redirect_not_found" });
}
// Return early if no redirect user is set, and no email needs to be send.
if (!deletedOutOfOfficeEntry.toUser) {
return {};
}
const t = await getTranslation(ctx.user.locale ?? "en", "common");
const formattedStartDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.start);
const formattedEndDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.end);
await sendBookingRedirectNotification({
language: t,
fromEmail: ctx.user.email,
eventOwner: ctx.user.username || ctx.user.email,
toEmail: deletedOutOfOfficeEntry.toUser.email,
toName: deletedOutOfOfficeEntry.toUser.username || "",
dates: `${formattedStartDate} - ${formattedEndDate}`,
action: "cancel",
});
return {};
};
@@ -1,6 +1,7 @@
import { z } from "zod";
export const ZOutOfOfficeInputSchema = z.object({
uuid: z.string().nullish(),
dateRange: z.object({
startDate: z.date(),
endDate: z.date(),