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:
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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user