From 96af17d8d7b047284fd026957ebf2baae42753aa Mon Sep 17 00:00:00 2001 From: alannnc Date: Wed, 10 Jan 2024 10:04:02 -0700 Subject: [PATCH] feat: OOO booking-forwarding (#12653) * feat-profile-forwarding * fix for promises of handling * fix badge success color * clean up * fix suggested changes * Changed design on booking-forward pages and moar test * taking care of suggested changes, trpc errors and code cleaning * improve text * fix conflicting data-testid * fix unique data-testid * fix error css-global, email button styles, error conditional * rename files to match functionality, remove away ui * Add translations and migration * remove log * small fixes + improvements * fix styles to match new design * merge fixes * Fix styles dark mode * Solving merge conflicts from earlier * Fix/change test to match new elements * use trash icon button insted of dots (design issues) * only send email if toUserId is set * Fix date picker dark mode * merge with remote * removed status field from table and email its now for notify * small text improvement in email * check for team plan not paid plan * fixes and clean up due to removing status * fix old send request name to new behaviour * more naming improvements and text * remove status from handle-type * code clean up * fix type error --------- Co-authored-by: Peer Richelsen Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli --- .../DateRangePicker/DateSelect.css | 110 ++++++++ .../out-of-office/DateRangePicker/index.tsx | 47 ++++ apps/web/pages/[user].tsx | 49 +++- apps/web/pages/[user]/[type].tsx | 19 +- apps/web/pages/availability/index.tsx | 45 ++-- .../my-account/out-of-office/index.tsx | 241 ++++++++++++++++++ apps/web/playwright/out-of-office.e2e.ts | 101 ++++++++ apps/web/public/static/locales/en/common.json | 30 +++ packages/emails/email-manager.ts | 6 + .../BookingRedirectEmailNotification.tsx | 33 +++ packages/emails/src/templates/index.ts | 1 + .../booking-redirect-notification.ts | 36 +++ .../features/booking-redirect/handle-type.ts | 71 ++++++ .../features/booking-redirect/handle-user.ts | 59 +++++ packages/features/bookings/Booker/Booker.tsx | 29 ++- .../features/insights/filters/DateSelect.css | 62 +++-- .../settings/layouts/SettingsLayout.tsx | 1 + packages/features/shell/Shell.tsx | 7 +- .../migration.sql | 34 +++ packages/prisma/schema.prisma | 21 ++ .../server/routers/loggedInViewer/_router.tsx | 45 +++- .../loggedInViewer/outOfOffice.handler.ts | 239 +++++++++++++++++ .../loggedInViewer/outOfOffice.schema.ts | 15 ++ packages/ui/index.tsx | 10 + 24 files changed, 1249 insertions(+), 62 deletions(-) create mode 100644 apps/web/components/out-of-office/DateRangePicker/DateSelect.css create mode 100644 apps/web/components/out-of-office/DateRangePicker/index.tsx create mode 100644 apps/web/pages/settings/my-account/out-of-office/index.tsx create mode 100644 apps/web/playwright/out-of-office.e2e.ts create mode 100644 packages/emails/src/templates/BookingRedirectEmailNotification.tsx create mode 100644 packages/emails/templates/booking-redirect-notification.ts create mode 100644 packages/features/booking-redirect/handle-type.ts create mode 100644 packages/features/booking-redirect/handle-user.ts create mode 100644 packages/prisma/migrations/20240109041925_add_out_of_office_entry_table/migration.sql create mode 100644 packages/trpc/server/routers/loggedInViewer/outOfOffice.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/outOfOffice.schema.ts diff --git a/apps/web/components/out-of-office/DateRangePicker/DateSelect.css b/apps/web/components/out-of-office/DateRangePicker/DateSelect.css new file mode 100644 index 0000000000..6ea26081a2 --- /dev/null +++ b/apps/web/components/out-of-office/DateRangePicker/DateSelect.css @@ -0,0 +1,110 @@ +.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button { + box-shadow: none; + width: 100%; + background-color: transparent; + } + + /* Media query for screens larger than 768px */ + @media (max-width: 639) { + .custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button { + max-width: 400px; + } + } + + .recharts-cartesian-grid-horizontal line{ + @apply stroke-emphasis + } + + .tremor-DateRangePicker-button button{ + @apply !h-9 !max-h-9 border-default hover:border-emphasis + } + + .tremor-DateRangePicker-calendarButton, + .tremor-DateRangePicker-dropdownButton { + @apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0; + } + + .tremor-DateRangePicker-dropdownModal{ + @apply divide-none + } + + .tremor-DropdownItem-root{ + @apply !h-9 !max-h-9 bg-default hover:bg-subtle text-default hover:text-emphasis + } + + .tremor-DateRangePicker-calendarButtonText, + .tremor-DateRangePicker-dropdownButtonText { + @apply text-default; + } + + .tremor-DateRangePicker-calendarHeaderText{ + @apply !text-default + } + + .tremor-DateRangePicker-calendarHeader svg{ + @apply text-default + } + + .tremor-DateRangePicker-calendarHeader button{ + @apply hover:bg-emphasis shadow-none focus:ring-0 + } + + + .tremor-DateRangePicker-calendarHeader button:hover svg{ + @apply text-emphasis + } + + .tremor-DateRangePicker-calendarButtonIcon{ + @apply text-default + } + + .tremor-DateRangePicker-calendarModal, + .tremor-DateRangePicker-dropdownModal { + @apply bg-default border-subtle shadow-dropdown + } + + .tremor-DateRangePicker-calendarBodyDate button{ + @apply text-default hover:bg-emphasis + } + + .tremor-DateRangePicker-calendarBodyDate button:disabled, + .tremor-DateRangePicker-calendarBodyDate button[disabled]{ + @apply opacity-25 + } + + .tremor-DateRangePicker-calendarHeader button{ + @apply border-default text-default + } + + .tremor-DateRangePicker-calendarBodyDate .bg-gray-100{ + @apply bg-subtle + } + + .tremor-DateRangePicker-calendarBodyDate .bg-gray-500{ + @apply !bg-brand-default text-inverted + } + + + .tremor-Card-root { + @apply p-5 bg-default; + } + + .tremor-TableCell-root { + @apply pl-0; + } + + .recharts-responsive-container { + @apply -mx-4; + } + .tremor-Card-root > p { + @apply mb-2 text-base font-semibold; + } + + .tremor-Legend-legendItem { + @apply ml-2; + } + + .tremor-TableBody-root { + @apply divide-subtle; + } + \ No newline at end of file diff --git a/apps/web/components/out-of-office/DateRangePicker/index.tsx b/apps/web/components/out-of-office/DateRangePicker/index.tsx new file mode 100644 index 0000000000..5a20178822 --- /dev/null +++ b/apps/web/components/out-of-office/DateRangePicker/index.tsx @@ -0,0 +1,47 @@ +import type { BookingRedirectForm } from "@pages/settings/my-account/out-of-office"; +import { DateRangePicker } from "@tremor/react"; +import type { UseFormSetValue } from "react-hook-form"; + +import dayjs from "@calcom/dayjs"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import "./DateSelect.css"; + +interface IOutOfOfficeDateRangeSelectProps { + dateRange: [Date | null, Date | null, null]; + setDateRange: React.Dispatch>; + setValue: UseFormSetValue; +} + +const OutOfOfficeDateRangePicker = (props: IOutOfOfficeDateRangeSelectProps) => { + const { t } = useLocale(); + const { dateRange, setDateRange, setValue } = props; + return ( +
+ { + const [start, end] = datesArray; + + if (start) { + setDateRange([start, end as Date | null, null]); + } + if (start && end) { + setValue("startDate", start.toISOString()); + setValue("endDate", end.toISOString()); + } + }} + color="gray" + options={undefined} + enableDropdown={false} + placeholder={t("select_date_range")} + enableYearPagination={true} + minDate={dayjs().startOf("d").toDate()} + maxDate={dayjs().add(2, "y").endOf("d").toDate()} + /> +
+ ); +}; + +export { OutOfOfficeDateRangePicker }; diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 349c8be4b6..acb97c9bce 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -2,6 +2,8 @@ import type { DehydratedState } from "@tanstack/react-query"; import classNames from "classnames"; import type { GetServerSideProps, InferGetServerSidePropsType } from "next"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { encode } from "querystring"; import { Toaster } from "react-hot-toast"; import type { z } from "zod"; @@ -11,6 +13,7 @@ import { useEmbedStyles, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; +import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-user"; import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; @@ -40,6 +43,7 @@ import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect"; export function UserPage(props: InferGetServerSidePropsType) { const { users, profile, eventTypes, markdownStrippedBio, entity } = props; + const searchParams = useSearchParams(); const [user] = users; //To be used when we only have a single user, not dynamic group useTheme(profile.theme); @@ -59,6 +63,8 @@ export function UserPage(props: InferGetServerSidePropsType { @@ -77,6 +83,7 @@ export function UserPage(props: InferGetServerSidePropsType + {isRedirect && ( +
+

+ {t("user_redirect_title", { + username: fromUserNameRedirected, + })}{" "} + 🏝️ +

+

+ {t("user_redirect_description", { + profile: { + username: user.username, + }, + username: fromUserNameRedirected, + })}{" "} + 😄 +

+
+ )}
= async (cont const usernameList = getUsernameList(context.query.user as string); const isOrgContext = isValidOrgDomain && currentOrgDomain; const dataFetchStart = Date.now(); + let outOfOffice = false; + + 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 usersWithoutAvatar = await prisma.user.findMany({ where: { username: { @@ -400,11 +438,16 @@ export const getServerSideProps: GetServerSideProps = async (cont })); // if profile only has one public event-type, redirect to it - if (eventTypes.length === 1 && context.query.redirect !== "false") { + if (eventTypes.length === 1 && context.query.redirect !== "false" && !outOfOffice) { + // Redirect but don't change the URL + const urlDestination = `/${user.username}/${eventTypes[0].slug}`; + const { query } = context; + const urlQuery = new URLSearchParams(encode(query)); + return { redirect: { permanent: false, - destination: `/${user.username}/${eventTypes[0].slug}`, + destination: `${urlDestination}?${urlQuery}`, }, }; } @@ -421,7 +464,7 @@ export const getServerSideProps: GetServerSideProps = async (cont username: user.username, bio: user.bio, avatarUrl: user.avatarUrl, - away: user.away, + away: usernameList.length === 1 ? outOfOffice : user.away, verified: user.verified, })), entity: { diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index b328800c45..f36ecdc15b 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -4,6 +4,7 @@ import { z } from "zod"; import { Booker } from "@calcom/atoms"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking"; @@ -164,7 +165,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) { @@ -188,7 +189,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { organization: userOrgQuery(context.req, context.params?.orgSlug), }, select: { - away: true, + id: true, hideBranding: true, allowSEOIndexing: true, }, @@ -199,6 +200,18 @@ 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) { @@ -230,7 +243,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { length: eventData.length, metadata: eventData.metadata, }, - away: user?.away, + away: outOfOffice, user: username, slug, trpcState: ssr.dehydrate(), diff --git a/apps/web/pages/availability/index.tsx b/apps/web/pages/availability/index.tsx index d368c2a233..97fcdb0fe2 100644 --- a/apps/web/pages/availability/index.tsx +++ b/apps/web/pages/availability/index.tsx @@ -1,4 +1,5 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; +import Link from "next/link"; import { useRouter, usePathname } from "next/navigation"; import { useCallback } from "react"; @@ -104,24 +105,32 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab />
) : ( -
-
    - {schedules.map((schedule) => ( - - ))} -
-
+ <> +
+
    + {schedules.map((schedule) => ( + + ))} +
+
+
+ {t("temporarily_out_of_office")}{" "} + + {t("add_a_redirect")} + +
+ )} ); diff --git a/apps/web/pages/settings/my-account/out-of-office/index.tsx b/apps/web/pages/settings/my-account/out-of-office/index.tsx new file mode 100644 index 0000000000..1524422af4 --- /dev/null +++ b/apps/web/pages/settings/my-account/out-of-office/index.tsx @@ -0,0 +1,241 @@ +import { Trash2 } from "lucide-react"; +import React, { useState } from "react"; +import { useForm, useFormState } from "react-hook-form"; + +import dayjs from "@calcom/dayjs"; +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; +import { ShellMain } from "@calcom/features/shell/Shell"; +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, Meta, showToast, Select, SkeletonText, UpgradeTeamsBadge, Switch } from "@calcom/ui"; +import { TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; +import { OutOfOfficeDateRangePicker } from "@components/out-of-office/DateRangePicker"; + +export type BookingRedirectForm = { + startDate: string; + endDate: string; + toTeamUserId: number | null; +}; + +const OutOfOfficeSection = () => { + const { t } = useLocale(); + const utils = trpc.useContext(); + + const [dateRange, setDateRange] = useState<[Date | null, Date | null, null | null]>([ + dayjs().startOf("d").toDate(), + dayjs().add(1, "d").endOf("d").toDate(), + null, + ]); + const [profileRedirect, setProfileRedirect] = useState(false); + const [selectedMember, setSelectedMember] = useState<{ label: string; value: number | null } | null>(null); + + const { handleSubmit, setValue } = useForm({ + defaultValues: { + startDate: dateRange[0]?.toISOString(), + endDate: dateRange[1]?.toISOString(), + 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(); + const memberListOptions: { + value: number | null; + label: string; + }[] = + listMembers + ?.filter((member) => me?.data?.id !== member.id) + .map((member) => ({ + value: member.id || null, + label: member.name || "", + })) || []; + + return ( + <> +
{ + createOutOfOfficeEntry.mutate(data); + setValue("toTeamUserId", null); + setSelectedMember(null); + })}> +
+ {/* Add startDate and end date inputs */} +
+ {/* Add toggle to enable/disable redirect */} +
+ { + setProfileRedirect(state); + }} + label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")} + /> + {!hasTeamPlan && ( +
+ +
+ )} +
+
+ {profileRedirect && ( +
+

{t("team_member")}

+