feat: ooo-v2 (#13621)

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
alannnc
2024-04-09 18:29:39 -07:00
committed by GitHub
co-authored by zomars Peer Richelsen CarinaWolli Alex van Andel
parent f00849b8d1
commit 880ba8f1dd
40 changed files with 972 additions and 415 deletions
@@ -3,7 +3,6 @@ import type { GetServerSideProps } from "next";
import { encode } from "querystring";
import type { z } from "zod";
import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-user";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/lib/defaultEvents";
@@ -73,21 +72,12 @@ export type UserPageProps = {
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const usernameList = getUsernameList(context.query.user as string);
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
let outOfOffice = false;
const isOrgContext = isValidOrgDomain && !!currentOrgDomain;
if (usernameList.length === 1) {
const result = await handleUserRedirection({ username: usernameList[0] });
if (result && result.outOfOffice) {
outOfOffice = true;
}
if (result && result.redirect?.destination) {
return result;
}
}
const dataFetchStart = Date.now();
if (!isOrgContext) {
// If there is no org context, see if some redirect is setup due to org migration
@@ -176,7 +166,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const eventTypes = await getEventTypesPublic(user.id);
// if profile only has one public event-type, redirect to it
if (eventTypes.length === 1 && context.query.redirect !== "false" && !outOfOffice) {
if (eventTypes.length === 1 && context.query.redirect !== "false") {
// Redirect but don't change the URL
const urlDestination = `/${user.profile.username}/${eventTypes[0].slug}`;
const { query } = context;
@@ -202,9 +192,9 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
username: user.username,
bio: user.bio,
avatarUrl: user.avatarUrl,
away: usernameList.length === 1 ? outOfOffice : user.away,
verified: user.verified,
profile: user.profile,
away: user.away,
})),
entity: {
considerUnpublished: !isARedirectFromNonOrgLink && org?.slug === null,
@@ -87,25 +87,6 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
"max-w-3xl px-4 py-24"
)}>
{isRedirect && (
<div className="mb-8 rounded-md bg-blue-100 p-4 dark:border dark:bg-transparent">
<h2 className="text-default mb-2 text-sm font-semibold dark:text-white">
{t("user_redirect_title", {
username: fromUserNameRedirected,
})}{" "}
🏝️
</h2>
<p className="text-default text-sm">
{t("user_redirect_description", {
profile: {
username: user.username,
},
username: fromUserNameRedirected,
})}{" "}
😄
</p>
</div>
)}
<div className="mb-8 text-center">
<UserAvatar
size="xl"
@@ -2,7 +2,6 @@ import { type GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
@@ -105,7 +104,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const username = usernames[0];
const { rescheduleUid, bookingUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
let outOfOffice = false;
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
@@ -132,18 +131,6 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
notFound: true,
} as const;
}
// If user is found, quickly verify bookingRedirects
const result = await handleTypeRedirection({
userId: user.id,
username,
slug,
});
if (result && result.outOfOffice) {
outOfOffice = true;
}
if (result && result.redirect?.destination) {
return result;
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
@@ -176,9 +163,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
length: eventData.length,
metadata: eventData.metadata,
},
away: outOfOffice,
user: username,
slug,
away: false,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
isSEOIndexable: user?.allowSEOIndexing,
@@ -1,8 +1,9 @@
import React, { useState } from "react";
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 SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -11,6 +12,12 @@ import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import {
Button,
DateRangePicker,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
EmptyScreen,
Icon,
Meta,
Select,
showToast,
@@ -18,10 +25,9 @@ import {
Switch,
TableBody,
TableCell,
TableHead,
TableHeader,
TableNew,
TableRow,
TextArea,
UpgradeTeamsBadge,
} from "@calcom/ui";
@@ -31,12 +37,21 @@ export type BookingRedirectForm = {
dateRange: { startDate: Date; endDate: Date };
offset: number;
toTeamUserId: number | null;
reasonId: number;
notes?: string;
};
const OutOfOfficeSection = () => {
const CreateOutOfOfficeEntryModal = ({
openModal,
closeModal,
}: {
openModal: boolean;
closeModal: () => void;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
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);
@@ -45,28 +60,6 @@ const OutOfOfficeSection = () => {
endDate: dayjs().add(1, "d").endOf("d").toDate(),
});
const { handleSubmit, setValue, getValues, control } = useForm<BookingRedirectForm>({
defaultValues: {
dateRange: {
startDate: dateRange.startDate,
endDate: dateRange.endDate,
},
offset: dayjs().utcOffset(),
toTeamUserId: null,
},
});
const createOutOfOfficeEntry = trpc.viewer.outOfOfficeCreate.useMutation({
onSuccess: () => {
showToast(t("success_entry_created"), "success");
utils.viewer.outOfOfficeEntriesList.invalidate();
setProfileRedirect(false);
},
onError: (error) => {
showToast(t(error.message), "error");
},
});
const { hasTeamPlan } = useHasTeamPlan();
const { data: listMembers } = trpc.viewer.teams.listMembers.useQuery({});
const me = useMeQuery();
@@ -81,57 +74,59 @@ const OutOfOfficeSection = () => {
label: member.name || "",
})) || [];
const { handleSubmit, setValue, getValues, 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 (
<>
<form
onSubmit={handleSubmit((data) => {
createOutOfOfficeEntry.mutate(data);
setValue("toTeamUserId", null);
setSelectedMember(null);
})}>
<div className="border-subtle flex flex-col border border-b-0 border-t-0 p-6 px-6 py-8 text-sm">
{/* Add startDate and end date inputs */}
{/* Add toggle to enable/disable redirect */}
<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>
<div className="mt-4 grid grid-rows-2 gap-2 md:grid-cols-2">
{profileRedirect && (
<div>
<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>
)}
<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">{t("time_range")}</p>
<p className="text-emphasis mb-1 block text-sm font-medium capitalize">{t("dates")}</p>
<div>
<Controller
name="dateRange"
@@ -152,25 +147,109 @@ const OutOfOfficeSection = () => {
/>
</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>
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
color="primary"
type="submit"
disabled={createOutOfOfficeEntry.isPending}
data-testid="create-entry-ooo-redirect">
{t("create_entry")}
</Button>
</SectionBottomActions>
</form>
</>
</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.useContext();
const utils = trpc.useUtils();
const { data, isPending } = trpc.viewer.outOfOfficeEntriesList.useQuery();
const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
onSuccess: () => {
@@ -182,31 +261,75 @@ const OutOfOfficeEntriesList = () => {
showToast(`An error ocurred`, "error");
},
});
if (data === null || data?.length === 0 || data === undefined) return null;
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">
<TableHeader className="md:z-1">
<TableRow>
<TableHead className="rounded-tl-lg font-normal capitalize">{t("time_range")}</TableHead>
<TableHead className="font-normal">{t("redirect_to")}</TableHead>
<TableHead className="rounded-tr-lg font-normal">{t("action")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((item) => (
<TableRow key={item.id} data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
<TableCell>
<p className="px-2">
{dayjs.utc(item.start).format("ll")} - {dayjs.utc(item.end).format("ll")}
</p>
</TableCell>
<TableCell>
<p className="px-2">{item.toUser?.username || "N/A"}</p>
</TableCell>
<TableCell className="px-4">
<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"
@@ -220,13 +343,15 @@ const OutOfOfficeEntriesList = () => {
</TableRow>
))}
{isPending && (
<TableRow>
{new Array(6).fill(0).map((_, index) => (
<TableCell key={index}>
<SkeletonText className="h-8 w-full" />
</TableCell>
<>
{new Array(2).fill(0).map((_, index) => (
<TableRow key={index}>
<TableCell>
<SkeletonText className="h-12 w-full" />
</TableCell>
</TableRow>
))}
</TableRow>
</>
)}
{!isPending && (data === undefined || data.length === 0) && (
@@ -245,10 +370,32 @@ const OutOfOfficeEntriesList = () => {
const OutOfOfficePage = () => {
const { t } = useLocale();
const params = useSearchParams();
const openModalOnStart = !!params?.get("om");
useEffect(() => {
if (openModalOnStart) {
setOpenModal(true);
}
}, [openModalOnStart]);
const [openModal, setOpenModal] = useState(false);
return (
<>
<Meta title={t("out_of_office")} description={t("out_of_office_description")} borderInShellHeader />
<OutOfOfficeSection />
<Meta
title={t("out_of_office")}
description={t("out_of_office_description")}
borderInShellHeader={false}
CTA={
<Button
color="primary"
className="flex w-20 items-center justify-between px-4"
onClick={() => setOpenModal(true)}
data-testid="add_entry_ooo">
<Icon name="plus" size={16} /> {t("add")}
</Button>
}
/>
<CreateOutOfOfficeEntryModal openModal={openModal} closeModal={() => setOpenModal(false)} />
<OutOfOfficeEntriesList />
</>
);
+29 -15
View File
@@ -18,7 +18,14 @@ test.describe("Out of office", () => {
await page.goto("/settings/my-account/out-of-office");
await page.locator("data-testid=create-entry-ooo-redirect").click();
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();
});
@@ -56,14 +63,20 @@ test.describe("Out of office", () => {
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")
.locator("div")
.filter({ hasText: "Select team member" })
.first()
.click();
await page.locator("#react-select-2-option-0 div").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");
// send request
await page.getByTestId("create-entry-ooo-redirect").click();
@@ -84,18 +97,19 @@ test.describe("Out of office", () => {
user: { connect: { id: user.id } },
toUser: { connect: { id: userTo.id } },
createdAt: new Date(),
reason: {
connect: {
id: 1,
},
},
},
});
await page.goto(`/${user.username}`);
await page.waitForLoadState("networkidle");
const eventTypeLink = page.locator('[data-testid="event-type-link"]').first();
await eventTypeLink.click();
// regex to match username
expect(page.url()).toMatch(new RegExp(`/${userTo.username}`));
await page.goto(`/${userTo.username}/30-min`);
expect(page.url()).toMatch(new RegExp(`/${userTo.username}/30-min`));
await expect(page.getByTestId("away-emoji")).toBeTruthy();
});
});
+18 -3
View File
@@ -2276,7 +2276,7 @@
"user_redirect_title": "{{username}} is currently away for a brief period of time.",
"user_redirect_description": "In the meantime, {{profile.username}} will be in charge of all the new scheduled meetings on behalf of {{username}}.",
"out_of_office": "Out of office",
"out_of_office_description": "Configure actions in your profile while you are away.",
"out_of_office_description": "Let your bookers know when you're OOO.",
"send_request": "Send request",
"start_date_and_end_date_required": "Start date and end date are required",
"start_date_must_be_before_end_date": "Start date must be before end date",
@@ -2295,8 +2295,8 @@
"booking_redirect_request_title": "Booking Redirect Request",
"select_team_member": "Select team member",
"going_away_title": "Going away? Simply mark your profile link as unavailable for a period of time.",
"redirect_team_enabled": "Redirect your profile to another team member",
"redirect_team_disabled": "Redirect your profile to another team member (Team plan required)",
"redirect_team_enabled": "Provide a link to a team member when OOO",
"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",
"temporarily_out_of_office": "Temporarily Out-Of-Office?",
@@ -2366,6 +2366,21 @@
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
"cookie_consent_checkbox": "I consent to our privacy policy and cookie usage",
"make_a_call": "Make a Call",
"ooo_reasons_unspecified": "Unspecified",
"ooo_reasons_vacation": "Vacation",
"ooo_reasons_travel": "Travel",
"ooo_reasons_sick_leave": "Sick leave",
"ooo_reasons_public_holiday": "Public holiday",
"ooo_forwarding_to": "Forwarding to <span>{{username}}</span>",
"ooo_not_forwarding": "No forwarding",
"ooo_empty_title": "Create an OOO",
"ooo_empty_description": "Communicate to your bookers when you're not available to take bookings. They can still book you upon your return or you can forward them to a team member.",
"ooo_user_is_ooo": "{{displayName}} is OOO",
"ooo_slots_returning": "<0>{{displayName}}</0> can take their meetings while they are away.",
"ooo_slots_book_with": "Book {{displayName}}",
"ooo_create_entry_modal": "Create an Out of Office",
"ooo_select_reason": "Select reason",
"create_an_out_of_office": "Create an Out of Office",
"submit_feedback": "Submit Feedback",
"host_no_show": "Your host didn't show up",
"no_show_description": "You can reschedule another meeting with them",
@@ -12,6 +12,7 @@ const HAWAII_AND_NEWYORK_TEAM = [
workingHours: [{ userId: 1, days: [1, 2, 3, 4, 5], startTime: 780, endTime: 1260 }],
busy: [],
dateOverrides: [],
datesOutOfOffice: {},
},
{
timeZone: "Pacific/Honolulu", // GMT -10 per 22th of Aug, 2022
@@ -23,6 +24,7 @@ const HAWAII_AND_NEWYORK_TEAM = [
],
busy: [],
dateOverrides: [],
datesOutOfOffice: {},
},
];
+152
View File
@@ -5,6 +5,7 @@ import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
@@ -343,6 +344,13 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
end: dayjs(busy.end),
}));
const datesOutOfOffice = await getOutOfOfficeDays({
userId: user.id,
dateFrom,
dateTo,
availability,
});
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
log.debug(
@@ -363,6 +371,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
workingHours,
dateOverrides,
currentSeats,
datesOutOfOffice,
};
};
@@ -626,3 +635,146 @@ const _getBusyTimesFromDurationLimits = async (
}
}
};
interface GetUserAvailabilityParamsDTO {
userId: number;
dateFrom: Dayjs;
dateTo: Dayjs;
availability: (DateOverride | WorkingHours)[];
}
export interface IFromUser {
id: number;
displayName: string | null;
}
export interface IToUser {
id: number;
username: string | null;
displayName: string | null;
}
export interface IOutOfOfficeData {
[key: string]: {
fromUser: IFromUser | null;
toUser?: IToUser | null;
reason?: string | null;
emoji?: string | null;
};
}
const getOutOfOfficeDays = async (
...args: Parameters<typeof _getOutOfOfficeDays>
): Promise<ReturnType<typeof _getOutOfOfficeDays>> => {
return monitorCallbackAsync(_getOutOfOfficeDays, ...args);
};
const _getOutOfOfficeDays = async ({
userId,
dateFrom,
dateTo,
availability,
}: GetUserAvailabilityParamsDTO): Promise<IOutOfOfficeData> => {
const outOfOfficeDays = await prisma.outOfOfficeEntry.findMany({
where: {
userId,
OR: [
// outside of range
// (start <= 'dateTo' AND end >= 'dateFrom')
{
start: {
lte: dateTo.toISOString(),
},
end: {
gte: dateFrom.toISOString(),
},
},
// start is between dateFrom and dateTo but end is outside of range
// (start <= 'dateTo' AND end >= 'dateTo')
{
start: {
lte: dateTo.toISOString(),
},
end: {
gte: dateTo.toISOString(),
},
},
// end is between dateFrom and dateTo but start is outside of range
// (start <= 'dateFrom' OR end <= 'dateTo')
{
start: {
lte: dateFrom.toISOString(),
},
end: {
lte: dateTo.toISOString(),
},
},
],
},
select: {
id: true,
start: true,
end: true,
user: {
select: {
id: true,
name: true,
},
},
toUser: {
select: {
id: true,
username: true,
name: true,
},
},
reason: {
select: {
id: true,
emoji: true,
reason: true,
},
},
},
});
if (!outOfOfficeDays.length) {
return {};
}
return outOfOfficeDays.reduce((acc: IOutOfOfficeData, { start, end, toUser, user, reason }) => {
// here we should use startDate or today if start is before today
// consider timezone in start and end date range
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
? dayjs().utc().startOf("day")
: dayjs(start).utc().startOf("day");
// get number of day in the week and see if it's on the availability
const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort(
(a, b) => a - b
);
const endDateRange = dayjs(end).utc().endOf("day");
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
const dayNumberOnWeek = date.day();
if (!flattenDays?.includes(dayNumberOnWeek)) {
continue; // Skip to the next iteration if day not found in flattenDays
}
acc[date.format("YYYY-MM-DD")] = {
// @TODO: would be good having start and end availability time here, but for now should be good
// you can obtain that from user availability defined outside of here
fromUser: { id: user.id, displayName: user.name },
// optional chaining destructuring toUser
toUser: !!toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
reason: !!reason ? reason.reason : null,
emoji: !!reason ? reason.emoji : null,
};
}
return acc;
}, {});
};
@@ -24,7 +24,7 @@ export default class BookingRedirectNotification extends BaseEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.bookingRedirect.toEmail} <${this.bookingRedirect.toName}>`,
to: `${this.bookingRedirect.toName} <${this.bookingRedirect.toEmail}>`,
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
subject: this.bookingRedirect.language("booking_redirect_email_subject"),
html: await renderEmail("BookingRedirectEmailNotification", {
@@ -1,71 +0,0 @@
import prisma from "@calcom/prisma";
interface HandleTypeRedirectionProps {
userId: number;
slug: string;
username: string;
}
export const handleTypeRedirection = async (props: HandleTypeRedirectionProps) => {
const { userId, slug, username } = props;
const outOfOfficeEntryActive = await prisma.outOfOfficeEntry.findFirst({
select: {
userId: true,
start: true,
end: true,
toUserId: true,
toUser: {
select: {
id: true,
username: true,
},
},
},
where: {
userId: userId,
start: {
lte: new Date(),
},
end: {
gte: new Date(),
},
},
});
let redirectToSameEventSlug = false;
// @TODO: Should I validate if new user has same type as slug?
if (outOfOfficeEntryActive) {
if (outOfOfficeEntryActive.toUserId === null) {
return {
outOfOffice: true,
};
}
if (outOfOfficeEntryActive?.toUser) {
const eventType = await prisma.eventType.findFirst({
select: {
slug: true,
userId: true,
},
where: {
slug,
userId: outOfOfficeEntryActive.toUserId,
},
});
if (eventType) {
redirectToSameEventSlug = true;
}
return {
redirect: {
destination: `/${outOfOfficeEntryActive.toUser.username}${
redirectToSameEventSlug ? `/${slug}` : ""
}?redirected=true&username=${username}`,
permanent: false,
},
};
}
}
return {};
};
@@ -1,59 +0,0 @@
import prisma from "@calcom/prisma";
interface HandleUserRedirectionProps {
username: string;
}
export const handleUserRedirection = async (props: HandleUserRedirectionProps) => {
const { username } = props;
const fromUser = await prisma.user.findFirst({
where: {
username,
},
select: {
id: true,
},
});
if (fromUser) {
// If user is found quickly verify bookingRedirect
const outOfOfficeEntryActive = await prisma.outOfOfficeEntry.findFirst({
select: {
toUser: {
select: {
username: true,
},
},
toUserId: true,
userId: true,
start: true,
end: true,
},
where: {
userId: fromUser.id,
start: {
lte: new Date(),
},
end: {
gte: new Date(),
},
},
});
if (outOfOfficeEntryActive && outOfOfficeEntryActive.toUserId === null) {
return {
outOfOffice: true,
};
}
if (outOfOfficeEntryActive && outOfOfficeEntryActive.toUser?.username) {
return {
redirect: {
destination: `/${outOfOfficeEntryActive.toUser.username}?redirected=true&username=${username}`,
permanent: false,
},
};
}
}
return {};
};
+1 -23
View File
@@ -50,8 +50,6 @@ const BookerComponent = ({
onClickOverlayContinue,
onOverlaySwitchStateChange,
sessionUsername,
isRedirect,
fromUserNameRedirected,
rescheduleUid,
hasSession,
extraOptions,
@@ -93,6 +91,7 @@ const BookerComponent = ({
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter(
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
);
const totalWeekDays = 7;
const addonDays =
nonEmptyScheduleDays.length < extraDays
@@ -266,26 +265,6 @@ const BookerComponent = ({
"text-default flex min-h-full w-full flex-col items-center",
layout === BookerLayouts.MONTH_VIEW ? "overflow-visible" : "overflow-clip"
)}>
{/* redirect from other user profile */}
{isRedirect && (
<div className="mb-8 rounded-md bg-blue-100 p-4 dark:border dark:bg-transparent">
<h2 className="text-default mb-2 text-sm font-semibold">
{t("user_redirect_title", {
username: fromUserNameRedirected,
})}{" "}
🏝️
</h2>
<p className="text-default text-sm">
{t("user_redirect_description", {
profile: {
username: username,
},
username: fromUserNameRedirected,
})}{" "}
😄
</p>
</div>
)}
<div
ref={animationScope}
className={classNames(
@@ -403,7 +382,6 @@ const BookerComponent = ({
event={event}
/>
</BookerSection>
<BookerSection
key="timeslots"
area={{ default: "main", month_view: "timeslots" }}
@@ -100,20 +100,20 @@ export const AvailableTimeSlots = ({
))
)}
</div>
<div
ref={containerRef}
className={classNames(
limitHeight && "scroll-bar flex-grow overflow-auto md:h-[400px]",
!limitHeight && "flex h-full w-full flex-row gap-4"
)}>
{isLoading
? // Shows exact amount of days as skeleton.
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => <AvailableTimesSkeleton key={i} />)
: slotsPerDay.length > 0 &&
slotsPerDay.map((slots) => (
{isLoading && // Shows exact amount of days as skeleton.
Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => <AvailableTimesSkeleton key={i} />)}
{!isLoading &&
slotsPerDay.length > 0 &&
slotsPerDay.map((slots) => (
<div key={slots.date} className="scroll-bar h-full w-full overflow-y-auto overflow-x-hidden">
<AvailableTimes
className="scroll-bar w-full overflow-y-auto overflow-x-hidden"
key={slots.date}
showTimeFormatToggle={!isColumnView}
onTimeSelect={onTimeSelect}
slots={slots.slots}
@@ -121,7 +121,8 @@ export const AvailableTimeSlots = ({
showAvailableSeatsCount={showAvailableSeatsCount}
event={event}
/>
))}
</div>
))}
</div>
</>
);
@@ -41,6 +41,7 @@ export const DatePicker = ({
browsingDate={month ? dayjs(month) : undefined}
selected={dayjs(selectedDate)}
weekStart={weekdayToWeekIndex(event?.data?.users?.[0]?.weekStart)}
slots={schedule?.data?.slots}
/>
);
};
@@ -37,10 +37,14 @@ export const LargeCalendar = ({
if (!schedule.slots) return availableTimeslots;
for (const day in schedule.slots) {
availableTimeslots[day] = schedule.slots[day].map((slot) => ({
start: dayjs(slot.time).toDate(),
end: dayjs(slot.time).add(eventDuration, "minutes").toDate(),
}));
availableTimeslots[day] = schedule.slots[day].map((slot) => {
const { time, ...rest } = slot;
return {
start: dayjs(time).toDate(),
end: dayjs(time).add(eventDuration, "minutes").toDate(),
...rest,
};
});
}
return availableTimeslots;
@@ -0,0 +1,81 @@
import { Trans } from "next-i18next";
import { useRouter } from "next/navigation";
import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability";
import { classNames } from "@calcom/lib";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
interface IOutOfOfficeInSlotsProps {
date: string;
fromUser?: IOutOfOfficeData["anyDate"]["fromUser"];
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
emoji?: string;
reason?: string;
borderDashed?: boolean;
className?: string;
}
export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
const { t } = useLocale();
const { fromUser, toUser, emoji = "🏝️", borderDashed = true, date, className } = props;
const searchParams = useCompatSearchParams();
const router = useRouter();
if (!fromUser || !toUser) return null;
return (
<div className={classNames("relative h-full pb-5", className)}>
<div
className={classNames(
"flex h-full flex-col items-center justify-start rounded-md border bg-white px-4 py-4 font-normal dark:bg-transparent",
borderDashed && "border-dashed"
)}>
<div className="bg-emphasis flex h-14 w-14 flex-col items-center justify-center rounded-full">
<span className="m-auto text-center text-lg">{emoji}</span>
</div>
<div className="space-y-2 text-center">
<p className="mt-2 text-base font-bold">
{t("ooo_user_is_ooo", { displayName: fromUser.displayName })}
</p>
{fromUser?.displayName && toUser?.displayName && (
<p className="text-center text-sm">
<Trans
i18nKey="ooo_slots_returning"
values={{ displayName: toUser.displayName }}
default="<1>{{ displayName }}</1> can take their meetings while they are away."
components={[<strong key="username">username</strong>]}
/>
</p>
)}
</div>
{toUser?.id && (
<Button
className="mt-8 max-w-[90%]"
variant="button"
color="secondary"
onClick={() => {
// grab current dates and query params from URL
const month = searchParams.get("month");
const layout = searchParams.get("layout");
const targetDate = searchParams.get("date") || date;
// go to the booking page with the selected user and correct search param
// While being an org push will maintain the org context and just change the user in params
router.push(
`/${toUser.username}?${month ? `month=${month}&` : ""}date=${targetDate}${
layout ? `&layout=${layout}` : ""
}`
);
}}>
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
{t("ooo_slots_book_with", { displayName: toUser.displayName })}
</span>
</Button>
)}
</div>
</div>
);
};
@@ -13,7 +13,7 @@ export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEven
/**
* Wrapper hook around the trpc query that fetches
* the event curently viewed in the booker. It will get
* the event currently viewed in the booker. It will get
* the current event slug and username from the booker store.
*
* Using this hook means you only need to use one hook, instead
@@ -3,11 +3,14 @@ import * as HoverCard from "@radix-ui/react-hover-card";
import { AnimatePresence, m } from "framer-motion";
import { useCallback, useState } from "react";
import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability";
import dayjs from "@calcom/dayjs";
import { OutOfOfficeInSlots } from "@calcom/features/bookings/Booker/components/OutOfOfficeInSlots";
import type { Slots } from "@calcom/features/schedules";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
import type { IGetAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
import { Button, Icon, SkeletonText } from "@calcom/ui";
import { useBookerStore } from "../Booker/store";
@@ -25,7 +28,7 @@ type TOnTimeSelect = (
) => void;
type AvailableTimesProps = {
slots: Slots[string];
slots: IGetAvailableSlots["slots"][string];
onTimeSelect: TOnTimeSelect;
seatsPerTimeSlot?: number | null;
showAvailableSeatsCount?: boolean | null;
@@ -154,9 +157,7 @@ const SlotItem = ({
<m.div initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
<Button
variant={layout === "column_view" ? "icon" : "button"}
StartIcon={
layout === "column_view" ? "chevron-right" : undefined
}
StartIcon={layout === "column_view" ? "chevron-right" : undefined}
onClick={() =>
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
}>
@@ -166,7 +167,7 @@ const SlotItem = ({
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content side="top" align="end" sideOffset={2}>
<div className="text-emphasis bg-inverted text-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
<div className="text-emphasis bg-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
<div className="flex items-center gap-2">
<p>Busy</p>
</div>
@@ -195,6 +196,15 @@ export const AvailableTimes = ({
}: AvailableTimesProps) => {
const { t } = useLocale();
const oooAllDay = slots.every((slot) => slot.away);
if (oooAllDay) {
return <OOOSlot {...slots[0]} />;
}
// Display ooo in slots once but after or before slots
const oooBeforeSlots = slots[0] && slots[0].away;
const oooAfterSlots = slots[slots.length - 1] && slots[slots.length - 1].away;
return (
<div className={classNames("text-default flex flex-col", className)}>
<div className="h-full pb-4">
@@ -208,22 +218,52 @@ export const AvailableTimes = ({
</p>
</div>
)}
{slots.map((slot) => (
<SlotItem
key={slot.time}
onTimeSelect={onTimeSelect}
slot={slot}
selectedSlots={selectedSlots}
seatsPerTimeSlot={seatsPerTimeSlot}
showAvailableSeatsCount={showAvailableSeatsCount}
event={event}
/>
))}
{oooBeforeSlots && !oooAfterSlots && <OOOSlot {...slots[0]} />}
{slots.map((slot) => {
if (slot.away) return null;
return (
<SlotItem
key={slot.time}
onTimeSelect={onTimeSelect}
slot={slot}
selectedSlots={selectedSlots}
seatsPerTimeSlot={seatsPerTimeSlot}
showAvailableSeatsCount={showAvailableSeatsCount}
event={event}
/>
);
})}
{oooAfterSlots && !oooBeforeSlots && <OOOSlot {...slots[slots.length - 1]} className="pb-0" />}
</div>
</div>
);
};
interface IOOOSlotProps {
fromUser?: IOutOfOfficeData["anyDate"]["fromUser"];
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
reason?: string;
emoji?: string;
time?: string;
className?: string;
}
const OOOSlot: React.FC<IOOOSlotProps> = (props) => {
const { fromUser, toUser, reason, emoji, time, className = "" } = props;
return (
<OutOfOfficeInSlots
fromUser={fromUser}
toUser={toUser}
date={dayjs(time).format("YYYY-MM-DD")}
reason={reason}
emoji={emoji}
borderDashed
className={className}
/>
);
};
export const AvailableTimesSkeleton = () => (
<div className="flex w-[20%] flex-col only:w-full">
{/* Random number of elements between 1 and 6. */}
+39 -4
View File
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { shallow } from "zustand/shallow";
import type { IFromUser, IToUser } from "@calcom/core/getUserAvailability";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { useEmbedStyles } from "@calcom/embed-core/embed-iframe";
@@ -37,20 +38,38 @@ export type DatePickerProps = {
isPending?: boolean;
/** used to query the multiple selected dates */
eventSlug?: string;
/** To identify days that are not available and should display OOO and redirect if toUser exists */
slots?: Record<
string,
{
time: string;
userIds?: number[];
away?: boolean;
fromUser?: IFromUser;
toUser?: IToUser;
reason?: string;
emoji?: string;
}[]
>;
};
export const Day = ({
date,
active,
disabled,
away,
emoji,
...props
}: JSX.IntrinsicElements["button"] & {
active: boolean;
date: Dayjs;
away?: boolean;
emoji?: string | null;
}) => {
const { t } = useLocale();
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
return (
<button
type="button"
@@ -67,7 +86,8 @@ export const Day = ({
data-disabled={disabled}
disabled={disabled}
{...props}>
{date.date()}
{away && <span data-testid="away-emoji">{emoji}</span>}
{!away && date.date()}
{date.isToday() && (
<span
className={classNames(
@@ -110,6 +130,7 @@ const Days = ({
month,
nextMonthButton,
eventSlug,
slots,
...props
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
@@ -162,10 +183,20 @@ const Days = ({
const daysToRenderForTheMonth = days.map((day) => {
if (!day) return { day: null, disabled: true };
const dateKey = yyyymmdd(day);
const oooInfo = slots && slots?.[dateKey] ? slots?.[dateKey]?.find((slot) => slot.away) : null;
const included = includedDates?.includes(dateKey);
const excluded = excludedDates.includes(dateKey);
const isOOOAllDay = !!(slots && slots[dateKey] && slots[dateKey].every((slot) => slot.away));
const away = isOOOAllDay;
const disabled = away ? !oooInfo?.toUser : !included || excluded;
return {
day: day,
disabled:
(includedDates && !includedDates.includes(yyyymmdd(day))) || excludedDates.includes(yyyymmdd(day)),
disabled,
away,
emoji: oooInfo?.emoji,
};
});
@@ -200,7 +231,7 @@ const Days = ({
return (
<>
{daysToRenderForTheMonth.map(({ day, disabled }, idx) => (
{daysToRenderForTheMonth.map(({ day, disabled, away, emoji }, idx) => (
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
{day === null ? (
<div key={`e-${idx}`} />
@@ -219,6 +250,8 @@ const Days = ({
}}
disabled={disabled}
active={isActive(day)}
away={away}
emoji={emoji}
/>
)}
</div>
@@ -237,6 +270,7 @@ const DatePicker = ({
locale,
selected,
onMonthChange,
slots,
...passThroughProps
}: DatePickerProps & Partial<React.ComponentProps<typeof Days>>) => {
const browsingDate = passThroughProps.browsingDate || dayjs().startOf("month");
@@ -307,6 +341,7 @@ const DatePicker = ({
browsingDate={browsingDate}
month={month}
nextMonthButton={() => changeMonth(+1)}
slots={slots}
/>
</div>
</div>
@@ -15,14 +15,14 @@ import { SchedulerColumns } from "./grid";
import { SchedulerHeading } from "./heading/SchedulerHeading";
import { HorizontalLines } from "./horizontalLines";
import { Spinner } from "./spinner/Spinner";
import { VeritcalLines } from "./verticalLines";
import { VerticalLines } from "./verticalLines";
export function Calendar(props: CalendarComponentProps) {
const container = useRef<HTMLDivElement | null>(null);
const containerNav = useRef<HTMLDivElement | null>(null);
const containerOffset = useRef<HTMLDivElement | null>(null);
const schedulerGrid = useRef<HTMLOListElement | null>(null);
const initalState = useCalendarStore((state) => state.initState);
const initialState = useCalendarStore((state) => state.initState);
const { timezone } = useTimePreferences();
const startDate = useCalendarStore((state) => state.startDate);
@@ -42,10 +42,10 @@ export function Calendar(props: CalendarComponentProps) {
const numberOfGridStopsPerDay = hours.length * usersCellsStopsPerHour;
const hourSize = 58;
// Initalise State on inital mount
// Initalise State on initial mount
useEffect(() => {
initalState(props);
}, [props, initalState]);
initialState(props);
}, [props, initialState]);
return (
<MobileNotSupported>
@@ -81,7 +81,7 @@ export function Calendar(props: CalendarComponentProps) {
numberOfGridStopsPerCell={usersCellsStopsPerHour}
containerOffsetRef={containerOffset}
/>
<VeritcalLines days={days} />
<VerticalLines days={days} />
<SchedulerColumns
offsetHeight={containerOffset.current?.offsetHeight}
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { classNames } from "@calcom/lib";
import { OutOfOfficeInSlots } from "../../../../bookings/Booker/components/OutOfOfficeInSlots";
import { useCalendarStore } from "../../state/store";
import type { CalendarAvailableTimeslots } from "../../types/state";
import type { GridCellToDateProps } from "../../utils";
@@ -26,9 +27,9 @@ export function EmptyCell(props: EmptyCellProps) {
timezone: props.timezone,
});
const minuesFromStart = (cellToDate.hour() - props.startHour) * 60 + cellToDate.minute();
const minutesFromStart = (cellToDate.hour() - props.startHour) * 60 + cellToDate.minute();
return <Cell topOffsetMinutes={minuesFromStart} timeSlot={dayjs(cellToDate).tz(props.timezone)} />;
return <Cell topOffsetMinutes={minutesFromStart} timeSlot={dayjs(cellToDate).tz(props.timezone)} />;
}
type AvailableCellProps = {
@@ -39,32 +40,91 @@ type AvailableCellProps = {
export function AvailableCellsForDay({ availableSlots, day, startHour }: AvailableCellProps) {
const { timezone } = useTimePreferences();
const date = dayjs(day);
const dateFormatted = date.format("YYYY-MM-DD");
const slotsForToday = availableSlots && availableSlots[dateFormatted];
const slotsForToday = availableSlots && availableSlots[dayjs(day).format("YYYY-MM-DD")];
const slots = useMemo(() => {
const calculatedSlots: {
slot: CalendarAvailableTimeslots[string][number];
topOffsetMinutes: number;
firstSlot?: CalendarAvailableTimeslots[string][number];
timezone?: string;
}[] = [];
const slots = useMemo(
() =>
slotsForToday?.map((slot) => ({
slot,
topOffsetMinutes:
(dayjs(slot.start).tz(timezone).hour() - startHour) * 60 + dayjs(slot.start).tz(timezone).minute(),
})),
[slotsForToday, startHour, timezone]
);
// first and last slot for ooo to display range correctly in week view
let firstSlotIndex = -1;
let lastSlotIndex = -1;
let areAllSlotsAway = true;
let startEndTimeDuration = 0;
if (!availableSlots) return null;
slotsForToday?.forEach((slot, index) => {
const startTime = dayjs(slot.start).tz(timezone);
const topOffsetMinutes = (startTime.hour() - startHour) * 60 + startTime.minute();
calculatedSlots.push({ slot, topOffsetMinutes });
if (!slot.away) {
areAllSlotsAway = false;
} else {
if (firstSlotIndex === -1) {
firstSlotIndex = index;
}
lastSlotIndex = index;
}
});
if (areAllSlotsAway && firstSlotIndex !== -1) {
const firstSlot = slotsForToday[firstSlotIndex];
const lastSlot = slotsForToday[lastSlotIndex];
startEndTimeDuration = dayjs(lastSlot.end).diff(dayjs(firstSlot.start), "minutes");
if (firstSlot.toUser == null) {
return null;
}
// This will return null if all slots are away and the first slot has no user
return {
slots: calculatedSlots,
startEndTimeDuration,
firstSlot,
timezone,
};
}
return { slots: calculatedSlots, startEndTimeDuration };
}, [slotsForToday, startHour, timezone]);
if (slots === null) return null;
if (slots.startEndTimeDuration) {
const { firstSlot, startEndTimeDuration } = slots;
return (
<CustomCell
timeSlot={dayjs(firstSlot?.start).tz(slots.timezone)}
topOffsetMinutes={slots.slots[0]?.topOffsetMinutes}
startEndTimeDuration={startEndTimeDuration}>
<OutOfOfficeInSlots
fromUser={firstSlot?.fromUser}
toUser={firstSlot?.toUser}
reason={firstSlot?.reason}
emoji={firstSlot?.emoji}
borderDashed={false}
date={dateFormatted}
className="pb-0"
/>
</CustomCell>
);
}
return (
<>
{slots?.map((slot, index) => {
return (
<Cell
key={index}
timeSlot={dayjs(slot.slot.start).tz(timezone)}
topOffsetMinutes={slot.topOffsetMinutes}
/>
);
})}
{slots.slots.map((slot, index) => (
<Cell
key={index}
timeSlot={dayjs(slot.slot.start).tz(timezone)}
topOffsetMinutes={slot.topOffsetMinutes}
/>
))}
</>
);
}
@@ -125,3 +185,33 @@ function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
</div>
);
}
function CustomCell({
timeSlot,
children,
topOffsetMinutes,
startEndTimeDuration,
}: CellProps & { children: React.ReactNode; startEndTimeDuration?: number }) {
return (
<div
className={classNames(
"bg-default dark:bg-muted group absolute z-[65] flex w-[calc(100%-1px)] items-center justify-center"
)}
data-slot={timeSlot.toISOString()}
style={{
top: topOffsetMinutes ? `calc(${topOffsetMinutes}*var(--one-minute-height))` : undefined,
overflow: "visible",
}}>
<div
className={classNames(
"dark:border-emphasis bg-default dark:bg-muted cursor-pointer rounded-[4px] p-[6px] text-xs font-semibold dark:text-white"
)}
style={{
height: `calc(${startEndTimeDuration}*var(--one-minute-height) - 2px)`,
width: "calc(100% - 2px)",
}}>
{children}
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import type dayjs from "@calcom/dayjs";
export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
export const VerticalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
const isRTL = () => {
let userLanguage = "en"; // Default to 'en' if navigator is not defined
@@ -1,3 +1,4 @@
import type { IFromUser, IToUser } from "@calcom/core/getUserAvailability";
import type { TimeRange } from "@calcom/types/schedule";
import type { CalendarEvent } from "./events";
@@ -50,11 +51,18 @@ export type CalendarPrivateActions = {
setSelectedEvent: (event: CalendarEvent) => void;
handleDateChange: (payload: "INCREMENT" | "DECREMENT") => void;
};
type TimeRangeExtended = TimeRange & {
away?: boolean;
fromUser?: IFromUser;
toUser?: IToUser;
reason?: string;
emoji?: string;
};
export type CalendarAvailableTimeslots = {
// Key is the date in YYYY-MM-DD format
// start and end are ISOstring
[key: string]: TimeRange[];
[key: string]: TimeRangeExtended[];
};
export type CalendarState = {
@@ -1,4 +1,4 @@
export { useSchedule } from "./useSchedule";
export { useSlotsForDate } from "./useSlotsForDate";
export { useNonEmptyScheduleDays } from "./useNonEmptyScheduleDays";
export type { Slots } from "./types";
export type { Slots, GetSchedule } from "./types";
@@ -1,3 +1,5 @@
import type { RouterOutputs } from "@calcom/trpc/react";
export type Slots = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"]["slots"];
export type GetSchedule = RouterOutputs["viewer"]["public"]["slots"]["getSchedule"];
@@ -4,7 +4,16 @@ import type { Slots } from "../use-schedule";
export const getNonEmptyScheduleDays = (slots?: Slots) => {
if (typeof slots === "undefined") return [];
return Object.keys(slots).filter((day) => slots[day].length > 0);
const nonEmptyDays: string[] = [];
Object.keys(slots).forEach((date) => {
if (slots[date].some((slot) => !(slot?.away && !slot.toUser) && slots[date].length > 0)) {
nonEmptyDays.push(date);
}
});
return nonEmptyDays;
};
export const useNonEmptyScheduleDays = (slots?: Slots) => {
@@ -3,7 +3,7 @@ import { useMemo } from "react";
import type { Slots } from "./types";
/**
* Get's slots for a specific date from the schedul cache.
* Get's slots for a specific date from the schedule cache.
* @param date Format YYYY-MM-DD
* @param scheduleCache Instance of useScheduleWithCache
*/
@@ -645,7 +645,7 @@ export default function SettingsLayout({
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
}>
<div className="flex flex-1 [&>*]:flex-1">
<div className="mx-auto max-w-full justify-center lg:max-w-4xl">
<div className="mx-auto max-w-full justify-center lg:max-w-3xl">
<ShellHeader />
<ErrorBoundary>
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
+41 -3
View File
@@ -1,3 +1,4 @@
import type { IFromUser, IOutOfOfficeData, IToUser } from "@calcom/core/getUserAvailability";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { WorkingHours, TimeRange as DateOverride } from "@calcom/types/schedule";
@@ -16,6 +17,7 @@ export type GetSlots = {
eventLength: number;
offsetStart?: number;
organizerTimeZone: string;
datesOutOfOffice?: IOutOfOfficeData;
};
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
@@ -147,6 +149,7 @@ function buildSlotsWithDateRanges({
minimumBookingNotice,
organizerTimeZone,
offsetStart,
datesOutOfOffice,
}: {
dateRanges: DateRange[];
frequency: number;
@@ -155,12 +158,21 @@ function buildSlotsWithDateRanges({
minimumBookingNotice: number;
organizerTimeZone: string;
offsetStart?: number;
datesOutOfOffice?: IOutOfOfficeData;
}) {
// keep the old safeguards in; may be needed.
frequency = minimumOfOne(frequency);
eventLength = minimumOfOne(eventLength);
offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0;
const slots: { time: Dayjs; userIds?: number[] }[] = [];
const slots: {
time: Dayjs;
userIds?: number[];
away?: boolean;
fromUser?: IFromUser;
toUser?: IToUser;
reason?: string;
emoji?: string;
}[] = [];
let interval = Number(process.env.NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL) || 1;
const intervalsWithDefinedStartTimes = [60, 30, 20, 15, 10, 5];
@@ -173,6 +185,7 @@ function buildSlotsWithDateRanges({
}
dateRanges.forEach((range) => {
const dateYYYYMMDD = range.start.format("YYYY-MM-DD");
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
let slotStartTime = range.start.utc().isAfter(startTimeWithMinNotice)
@@ -194,10 +207,33 @@ function buildSlotsWithDateRanges({
slotStartTime = slotStartTime.add(offsetStart ?? 0, "minutes").tz(timeZone);
while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").utc().isAfter(rangeEnd)) {
slots.push({
const dateOutOfOfficeExists = datesOutOfOffice?.[dateYYYYMMDD];
let slotData: {
time: Dayjs;
userIds?: number[];
away?: boolean;
fromUser?: IFromUser;
toUser?: IToUser;
reason?: string;
emoji?: string;
} = {
time: slotStartTime,
});
};
if (dateOutOfOfficeExists) {
const { toUser, fromUser, reason, emoji } = dateOutOfOfficeExists;
slotData = {
time: slotStartTime,
away: true,
...(fromUser && { fromUser }),
...(toUser && { toUser }),
...(reason && { reason }),
...(emoji && { emoji }),
};
}
slots.push(slotData);
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
}
});
@@ -221,6 +257,7 @@ const getSlots = ({
eventLength,
offsetStart = 0,
organizerTimeZone,
datesOutOfOffice,
}: GetSlots) => {
if (dateRanges) {
const slots = buildSlotsWithDateRanges({
@@ -231,6 +268,7 @@ const getSlots = ({
minimumBookingNotice,
organizerTimeZone,
offsetStart,
datesOutOfOffice,
});
return slots;
}
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "OutOfOfficeEntry" ADD COLUMN "reasonId" INTEGER;
-- CreateTable
CREATE TABLE "OutOfOfficeReason" (
"id" SERIAL NOT NULL,
"emoji" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"userId" INTEGER,
CONSTRAINT "OutOfOfficeReason_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OutOfOfficeReason_reason_key" ON "OutOfOfficeReason"("reason");
-- AddForeignKey
ALTER TABLE "OutOfOfficeEntry" ADD CONSTRAINT "OutOfOfficeEntry_reasonId_fkey" FOREIGN KEY ("reasonId") REFERENCES "OutOfOfficeReason"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OutOfOfficeReason" ADD CONSTRAINT "OutOfOfficeReason_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- Seed data for stock reasons
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('🕒', 'ooo_reasons_unspecified', true);
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('🏝️', 'ooo_reasons_vacation', true);
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('🛫', 'ooo_reasons_travel', true);
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('🤒', 'ooo_reasons_sick_leave', true);
INSERT INTO "OutOfOfficeReason" ("emoji", "reason", "enabled") VALUES ('📅', 'ooo_reasons_public_holiday', true);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OutOfOfficeEntry" ADD COLUMN "notes" TEXT;
+20 -4
View File
@@ -303,6 +303,8 @@ model User {
secondaryEmails SecondaryEmail[]
isPlatformManaged Boolean @default(false)
OutOfOfficeReasons OutOfOfficeReason[]
@@unique([email])
@@unique([email, username])
@@unique([username, organizationId])
@@ -1177,14 +1179,17 @@ model Avatar {
}
model OutOfOfficeEntry {
id Int @id @default(autoincrement())
uuid String @unique
id Int @id @default(autoincrement())
uuid String @unique
start DateTime
end DateTime
notes String?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
toUserId Int?
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
reasonId Int?
reason OutOfOfficeReason? @relation(fields: [reasonId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1195,6 +1200,17 @@ model OutOfOfficeEntry {
@@index([start, end])
}
model OutOfOfficeReason {
id Int @id @default(autoincrement())
emoji String
reason String @unique
enabled Boolean @default(true)
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
entries OutOfOfficeEntry[]
}
// Platform
model PlatformOAuthClient {
id String @id @default(cuid())
@@ -55,6 +55,7 @@ type AppsRouterHandlerCache = {
outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList;
outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete;
addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler;
outOfOfficeReasonList?: typeof import("./outOfOfficeReasons.handler").outOfOfficeReasonList;
};
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
@@ -484,4 +485,18 @@ export const loggedInViewerRouter = router({
return UNSTABLE_HANDLER_CACHE.addSecondaryEmail({ ctx, input });
}),
outOfOfficeReasonList: authedProcedure.query(async () => {
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList) {
UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList = (
await import("./outOfOfficeReasons.handler")
).outOfOfficeReasonList;
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList();
}),
});
@@ -101,6 +101,10 @@ export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
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" });
}
// Prevent infinite redirects but consider time ranges
const existingOutOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
select: {
@@ -142,7 +146,9 @@ export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
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(),
@@ -222,7 +228,7 @@ export const outOfOfficeEntriesList = async ({ ctx }: { ctx: { user: NonNullable
},
},
orderBy: {
start: "desc",
start: "asc",
},
select: {
id: true,
@@ -235,6 +241,15 @@ export const outOfOfficeEntriesList = async ({ ctx }: { ctx: { user: NonNullable
username: true,
},
},
reason: {
select: {
id: true,
emoji: true,
reason: true,
userId: true,
},
},
notes: true,
},
});
@@ -7,6 +7,8 @@ export const ZOutOfOfficeInputSchema = z.object({
}),
offset: z.number(),
toTeamUserId: z.number().nullable(),
reasonId: z.number(),
notes: z.string().nullable().optional(),
});
export type TOutOfOfficeInputSchema = z.infer<typeof ZOutOfOfficeInputSchema>;
@@ -0,0 +1,11 @@
import prisma from "@calcom/prisma";
export const outOfOfficeReasonList = async () => {
const outOfOfficeReasons = await prisma.outOfOfficeReason.findMany({
where: {
enabled: true,
},
});
return outOfOfficeReasons;
};
@@ -4,7 +4,7 @@ import { v4 as uuid } from "uuid";
import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability";
import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes";
import type { CurrentSeats } from "@calcom/core/getUserAvailability";
import type { CurrentSeats, IFromUser, IToUser } from "@calcom/core/getUserAvailability";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
@@ -296,7 +296,23 @@ function applyOccupiedSeatsToCurrentSeats(currentSeats: CurrentSeats, occupiedSe
return currentSeats;
}
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
export interface IGetAvailableSlots {
slots: Record<
string,
{
time: string;
attendees?: number | undefined;
bookingUid?: string | undefined;
away?: boolean | undefined;
fromUser?: IFromUser | undefined;
toUser?: IToUser | undefined;
reason?: string | undefined;
emoji?: string | undefined;
}[]
>;
}
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<IGetAvailableSlots> {
const orgDetails = input?.orgSlug
? {
currentOrgDomain: input.orgSlug,
@@ -447,6 +463,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
dateRanges,
currentSeats: _currentSeats,
timeZone,
datesOutOfOffice,
} = await getUserAvailability(
{
userId: currentUser.id,
@@ -481,6 +498,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
dateRanges,
busy,
user: currentUser,
datesOutOfOffice,
};
})
);
@@ -514,6 +532,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
frequency: eventType.slotInterval || input.duration || eventType.length,
organizerTimeZone:
eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone,
datesOutOfOffice: allUsersAvailability[0]?.datesOutOfOffice,
});
let availableTimeSlots: typeof timeSlots = [];
+2 -1
View File
@@ -175,11 +175,12 @@ export function DialogHeader(props: DialogHeaderProps) {
type DialogFooterProps = {
children: React.ReactNode;
showDivider?: boolean;
noSticky?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
export function DialogFooter(props: DialogFooterProps) {
return (
<div className={classNames("bg-default sticky bottom-0", props.className)}>
<div className={classNames("bg-default bottom-0", props?.noSticky ? "" : "sticky", props.className)}>
{props.showDivider && (
// TODO: the -mx-8 is causing overflow in the dialog buttons
<hr data-testid="divider" className="border-subtle -mx-8" />
@@ -9,6 +9,7 @@ import { Button } from "../../components/button";
export function EmptyScreen({
Icon: icon,
customIcon,
avatar,
headline,
description,
@@ -22,6 +23,7 @@ export function EmptyScreen({
iconWrapperClassName,
}: {
Icon?: IconName;
customIcon?: React.ReactElement;
avatar?: React.ReactElement;
headline: string | React.ReactElement;
description?: string | React.ReactElement;
@@ -59,6 +61,7 @@ export function EmptyScreen({
/>
</div>
)}
{!customIcon ? null : <>{customIcon}</>}
<div className="flex max-w-[420px] flex-col items-center">
<h2
className={classNames(