Recurring event improvements (#2750)

* Loading state and rejected bookings gone

* Listing fixes

* Tweaking upcoming vs recurring bookings tabs

* Tweaking new emails to avoid recurringEvent param

* Tweaks to support recurring events in common flows

* Missed one renderEmail

* Removing uneeded references

* Reverting manual plural fixes

* Refactoring recurring event strings

* Correcting merge issues

* Relying on newly introduced obj

* Fixing mobile

* Final tweaks

* Latest fixes

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Leo Giovanetti
2022-06-10 17:38:06 -03:00
committed by GitHub
co-authored by Peer Richelsen Bailey Pumfleet kodiakhq[bot]
parent 67a7c578b1
commit 514fca7a53
25 changed files with 999 additions and 510 deletions
+40 -25
View File
@@ -18,7 +18,7 @@ import { parseRecurringEvent } from "@calcom/lib";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Frequency } from "@calcom/prisma/zod-utils";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { Tooltip } from "@calcom/ui/Tooltip";
@@ -63,9 +63,9 @@ function BookingListItem(booking: BookingItemProps) {
};
/**
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
* the "Upcoming" tab, to support confirming discretionally in the "Recurring" tab.
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
*/
if (booking.listingStatus === "upcoming" && booking.recurringEventId !== null) {
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
const res = await fetch("/api/book/confirm", {
@@ -96,7 +96,7 @@ function BookingListItem(booking: BookingItemProps) {
{
id: "reject",
label:
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
booking.listingStatus === "recurring" && booking.recurringEventId !== null
? t("reject_all")
: t("reject"),
onClick: () => {
@@ -108,7 +108,7 @@ function BookingListItem(booking: BookingItemProps) {
{
id: "confirm",
label:
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
booking.listingStatus === "recurring" && booking.recurringEventId !== null
? t("confirm_all")
: t("confirm"),
onClick: () => {
@@ -120,10 +120,13 @@ function BookingListItem(booking: BookingItemProps) {
},
];
const bookedActions: ActionType[] = [
let bookedActions: ActionType[] = [
{
id: "cancel",
label: t("cancel"),
label:
booking.listingStatus === "recurring" && booking.recurringEventId !== null
? t("cancel_all_remaining")
: t("cancel"),
href: `/cancel/${booking.uid}`,
icon: XIcon,
},
@@ -159,6 +162,10 @@ function BookingListItem(booking: BookingItemProps) {
},
];
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
}
const RequestSentMessage = () => {
return (
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
@@ -191,10 +198,12 @@ function BookingListItem(booking: BookingItemProps) {
setLocationMutation.mutate({ bookingId: booking.id, newLocation });
};
// Calculate the booking date(s)
// Calculate the booking date(s) and setup recurring event data to show
let recurringStrings: string[] = [];
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== null) {
[recurringStrings] = parseRecurringDates(
let recurringDates: Date[] = [];
const today = new Date();
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== undefined) {
[recurringStrings, recurringDates] = parseRecurringDates(
{
startDate: booking.startTime,
recurringEvent: parseRecurringEvent(booking.eventType.recurringEvent),
@@ -202,6 +211,11 @@ function BookingListItem(booking: BookingItemProps) {
},
i18n
);
if (booking.status === BookingStatus.PENDING) {
// Only take into consideration next up instances if booking is confirmed
recurringDates = recurringDates.filter((aDate) => aDate >= today);
recurringStrings = recurringDates.map((_, key) => recurringStrings[key]);
}
}
let location = booking.location || "";
@@ -285,9 +299,7 @@ function BookingListItem(booking: BookingItemProps) {
</Dialog>
<tr className="flex hover:bg-neutral-50">
<td
className="hidden whitespace-nowrap align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-56"
onClick={onClick}>
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-64" onClick={onClick}>
<div className="cursor-pointer py-4">
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
<div className="text-sm text-gray-500">
@@ -297,24 +309,27 @@ function BookingListItem(booking: BookingItemProps) {
<div className="text-sm text-gray-400">
{booking.recurringCount &&
booking.eventType?.recurringEvent?.freq &&
booking.listingStatus === "upcoming" && (
(booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
<div className="flex">
<Tooltip
content={recurringStrings.map((aDate, key) => (
<p key={key}>{aDate}</p>
))}>
<p className="text-gray-600 dark:text-white">
<RefreshIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
{`${t("every_for_freq", {
freq: t(
`${Frequency[booking.eventType.recurringEvent.freq].toString().toLowerCase()}`
),
})} ${booking.recurringCount} ${t(
`${Frequency[booking.eventType.recurringEvent.freq].toString().toLowerCase()}`,
{ count: booking.recurringCount }
)}`}
</p>
<div className="text-gray-600 dark:text-white">
<RefreshIcon className="float-left mr-1 mt-[2px] inline-block h-4 w-4 text-gray-400" />
<p className="pl-[21px]">
{booking.status === BookingStatus.ACCEPTED
? `${t("event_remaining", {
count: recurringDates.length,
})}`
: getEveryFreqFor({
t,
recurringEvent: booking.eventType.recurringEvent,
recurringCount: booking.recurringCount,
})}
</p>
</div>
</Tooltip>
</div>
</div>
+10 -6
View File
@@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
import { Button } from "@calcom/ui/Button";
import useTheme from "@lib/hooks/useTheme";
@@ -17,6 +18,7 @@ type Props = {
name: string | null;
slug: string | null;
};
recurringEvent: RecurringEvent | null;
team?: string | null;
setIsCancellationMode: (value: boolean) => void;
theme: string | null;
@@ -59,11 +61,13 @@ export default function CancelBooking(props: Props) {
rows={3}
/>
<div className="flex rtl:space-x-reverse">
<div className="w-full">
<Button color="secondary" onClick={() => router.push("/reschedule/" + booking?.uid)}>
{t("reschedule_this")}
</Button>
</div>
{!props.recurringEvent && (
<div className="w-full">
<Button color="secondary" onClick={() => router.push("/reschedule/" + booking?.uid)}>
{t("reschedule_this")}
</Button>
</div>
)}
<div className="w-full space-x-2 text-right">
<Button color="secondary" onClick={() => props.setIsCancellationMode(false)}>
{t("nevermind")}
@@ -92,7 +96,7 @@ export default function CancelBooking(props: Props) {
await router.push(
`/cancel/success?name=${props.profile.name}&title=${booking?.title}&eventPage=${
profile.slug
}&team=${team ? 1 : 0}`
}&team=${team ? 1 : 0}&recurring=${!!props.recurringEvent}`
);
} else {
setLoading(false);
@@ -33,8 +33,8 @@ import {
import classNames from "@calcom/lib/classNames";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
import { localStorage } from "@calcom/lib/webstorage";
import { Frequency } from "@calcom/prisma/zod-utils";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
@@ -296,33 +296,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
{eventType.length} {t("minutes")}
</p>
{!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
<div className="text-gray-600 dark:text-white">
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<p className="mb-1 -ml-2 inline px-2 py-1">
{t("every_for_freq", {
freq: t(
`${Frequency[eventType.recurringEvent.freq].toString().toLowerCase()}`
),
})}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline text-gray-600 dark:text-white">
{t(`${Frequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
count: recurringEventCount,
})}
</p>
</div>
)}
{eventType.price > 0 && (
<div className="text-gray-600 dark:text-white">
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
@@ -335,31 +308,29 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
</IntlProvider>
</div>
)}
{!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
{!rescheduleUid && eventType.recurringEvent && (
<div className="text-gray-600 dark:text-white">
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<p className="mb-1 -ml-2 inline px-2 py-1">
{t("every_for_freq", {
freq: t(
`${Frequency[eventType.recurringEvent.freq].toString().toLowerCase()}`
),
})}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline text-gray-600 dark:text-white">
{t(`${Frequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
count: recurringEventCount,
})}
</p>
<RefreshIcon className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<div className="ml-[27px]">
<p className="mb-1 -ml-2 inline px-2 py-1">
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline text-gray-600 dark:text-white">
{t("occurrence", {
count: recurringEventCount,
})}
</p>
</div>
</div>
)}
<TimezoneDropdown />
@@ -460,29 +431,29 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
{eventType.length} {t("minutes")}
</p>
{!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
{!rescheduleUid && eventType.recurringEvent && (
<div className="text-gray-600 dark:text-white">
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<p className="mb-1 -ml-2 inline px-2 py-1">
{t("every_for_freq", {
freq: t(`${Frequency[eventType.recurringEvent.freq].toString().toLowerCase()}`),
})}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline text-gray-600 dark:text-white">
{t(`${Frequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
count: recurringEventCount,
})}
</p>
<RefreshIcon className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<div className="ml-[27px]">
<p className="mb-1 -ml-2 inline px-2 py-1">
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
</p>
<input
type="number"
min="1"
max={eventType.recurringEvent.count}
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
defaultValue={eventType.recurringEvent.count}
onChange={(event) => {
setRecurringEventCount(parseInt(event?.target.value));
}}
/>
<p className="inline text-gray-600 dark:text-white">
{t("occurrence", {
count: recurringEventCount,
})}
</p>
</div>
</div>
)}
{eventType.price > 0 && (
@@ -31,7 +31,7 @@ import {
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { Frequency } from "@calcom/prisma/zod-utils";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { createPaymentLink } from "@calcom/stripe/client";
import { Button } from "@calcom/ui/Button";
import { Tooltip } from "@calcom/ui/Tooltip";
@@ -517,12 +517,11 @@ const BookingPage = ({
<div className="mb-3 text-gray-600 dark:text-white">
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<p className="mb-1 -ml-2 inline px-2 py-1">
{`${t("every_for_freq", {
freq: t(`${Frequency[eventType.recurringEvent.freq].toString().toLowerCase()}`),
})} ${recurringEventCount} ${t(
`${Frequency[eventType.recurringEvent.freq].toString().toLowerCase()}`,
{ count: parseInt(recurringEventCount.toString()) }
)}`}
{getEveryFreqFor({
t,
recurringEvent: eventType.recurringEvent,
recurringCount: recurringEventCount,
})}
</p>
</div>
)}
@@ -64,7 +64,9 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
{recurringEvent?.count && recurringEvent.count > 0 && (
<li className="mr-4 flex items-center whitespace-nowrap">
<RefreshIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
{t("repeats_up_to", { count: recurringEvent.count })}
{t("repeats_up_to", {
count: recurringEvent.count,
})}
</li>
)}
{eventType.price > 0 && (
@@ -126,7 +126,7 @@ export default function RecurringEventController({
}}
/>
<p className="mr-2 text-neutral-900">
{t(`${Frequency[recurringEventState.freq].toString().toLowerCase()}`, {
{t("occurrence", {
count: recurringEventState.count,
})}
</p>
+1 -1
View File
@@ -221,7 +221,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
(eventType.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
? recurringEventCountQuery
? parseInt(recurringEventCountQuery)
: eventType.recurringEvent.count)) ||
null;
+1 -1
View File
@@ -224,7 +224,7 @@ async function patchHandler(req: NextApiRequest) {
}
if (recurringEventId) {
// The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related
// The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: {
+23 -9
View File
@@ -35,6 +35,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
select: {
...bookingMinimalSelect,
recurringEventId: true,
userId: true,
user: {
select: {
@@ -146,15 +147,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
// action always succeeds even if subsequent integrations fail cancellation.
await prisma.booking.update({
where: {
uid,
},
data: {
status: BookingStatus.CANCELLED,
cancellationReason: cancellationReason,
},
});
if (bookingToDelete.eventType?.recurringEvent) {
// Proceed to mark as cancelled all recurring event instances
await prisma.booking.updateMany({
where: {
recurringEventId: bookingToDelete.recurringEventId,
},
data: {
status: BookingStatus.CANCELLED,
cancellationReason: cancellationReason,
},
});
} else {
await prisma.booking.update({
where: {
uid,
},
data: {
status: BookingStatus.CANCELLED,
cancellationReason: cancellationReason,
},
});
}
/** TODO: Remove this without breaking functionality */
if (bookingToDelete.location === "integrations:daily") {
+14 -2
View File
@@ -58,7 +58,19 @@ export default function Bookings() {
}
return { recurringCount };
};
const shownBookings: Record<string, boolean> = {};
const filterBookings = (booking: BookingOutput) => {
if (status === "recurring" || status === "cancelled") {
if (!booking.recurringEventId) {
return true;
}
if (shownBookings[booking.recurringEventId]) {
return false;
}
shownBookings[booking.recurringEventId] = true;
}
return true;
};
return (
<Shell heading={t("bookings")} subtitle={t("bookings_description")} customLoader={<SkeletonLoader />}>
<WipeMyCalActionButton trpc={trpc} bookingStatus={status} bookingsEmpty={isEmpty} />
@@ -77,7 +89,7 @@ export default function Bookings() {
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.map((booking) => (
{page.bookings.filter(filterBookings).map((booking) => (
<BookingListItem
key={booking.id}
listingStatus={status}
+106 -17
View File
@@ -1,16 +1,20 @@
import { CalendarIcon, XIcon } from "@heroicons/react/solid";
import { CalendarIcon, XIcon, RefreshIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import dayjs from "dayjs";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { Button } from "@calcom/ui/Button";
import { TextField } from "@calcom/ui/form/fields";
import { asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { detectBrowserTimeFormat } from "@lib/timeFormat";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -28,8 +32,8 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(props.booking ? null : t("booking_already_cancelled"));
const [cancellationReason, setCancellationReason] = useState<string>("");
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
const telemetry = useTelemetry();
return (
<div>
<HeadSeo
@@ -67,27 +71,84 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
<div className="mt-3 sm:mt-5">
<h3
className="text-center text-lg font-medium leading-6 text-gray-900"
id="modal-headline">
{props.cancellationAllowed
? t("really_cancel_booking")
: t("cannot_cancel_booking")}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
{props.cancellationAllowed ? t("reschedule_instead") : t("event_is_in_the_past")}
<p className="text-center text-sm text-gray-500">
{props.cancellationAllowed && !props.booking?.eventType.recurringEvent
? t("reschedule_instead")
: t("event_is_in_the_past")}
</p>
</div>
<div className="mt-4 border-t border-b py-4">
<h2 className="font-cal mb-2 text-lg font-medium text-gray-600">
<h2 className="font-cal mb-2 text-center text-lg font-medium text-gray-600">
{props.booking?.title}
</h2>
<p className="text-gray-500">
<CalendarIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{dayjs(props.booking?.startTime).format(
detectBrowserTimeFormat + ", dddd DD MMMM YYYY"
{props.booking?.eventType.recurringEvent &&
props.booking?.eventType.recurringEvent.freq &&
props.recurringInstances && (
<div className="text-center text-gray-500">
<RefreshIcon className="mr-3 -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
<p className="mb-1 -ml-2 inline px-2 py-1">
{getEveryFreqFor({
t,
recurringEvent: props.booking.eventType.recurringEvent,
recurringCount: props.recurringInstances.length,
})}
</p>
</div>
)}
</p>
<div className="text-gray-500">
<div className="flex flex-row items-start justify-center space-x-3">
{props.booking?.eventType.recurringEvent && props.recurringInstances ? (
<>
<CalendarIcon className="mt-2 ml-1 h-4 w-4" />
<div className="mb-1 inline py-1 text-left">
<div className="">
{dayjs(props.recurringInstances[0].startTime).format(
detectBrowserTimeFormat + ", dddd DD MMMM YYYY"
)}
<Collapsible
open={moreEventsVisible}
onOpenChange={() => setMoreEventsVisible(!moreEventsVisible)}>
<CollapsibleTrigger
type="button"
className={classNames(
"-ml-4 block w-full text-center",
moreEventsVisible ? "hidden" : ""
)}>
{t("plus_more", { count: props.recurringInstances.length - 1 })}
</CollapsibleTrigger>
<CollapsibleContent>
{props.booking?.eventType.recurringEvent?.count &&
props.recurringInstances.slice(1).map((dateObj, idx) => (
<div key={idx} className="">
{dayjs(dateObj.startTime).format(
detectBrowserTimeFormat + ", dddd DD MMMM YYYY"
)}
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
</div>
</>
) : (
<>
<CalendarIcon className="mt-1 mr-1 h-4 w-4" />
{dayjs(props.booking?.startTime).format(
detectBrowserTimeFormat + ", dddd DD MMMM YYYY"
)}
</>
)}
</div>
</div>
</div>
</div>
</div>
@@ -101,9 +162,11 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
className="mb-5 sm:mb-6"
/>
<div className="space-x-2 text-center rtl:space-x-reverse">
<Button color="secondary" onClick={() => router.push("/reschedule/" + uid)}>
{t("reschedule_this")}
</Button>
{!props.booking.eventType?.recurringEvent && (
<Button color="secondary" onClick={() => router.push("/reschedule/" + uid)}>
{t("reschedule_this")}
</Button>
)}
<Button
data-testid="cancel"
onClick={async () => {
@@ -130,7 +193,7 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
props.booking.title
}&eventPage=${props.profile.slug}&team=${
props.booking.eventType?.team ? 1 : 0
}`
}&recurring=${!!props.booking.eventType?.recurringEvent}`
);
} else {
setLoading(false);
@@ -167,6 +230,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
select: {
...bookingMinimalSelect,
recurringEventId: true,
user: {
select: {
id: true,
@@ -178,6 +242,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
eventType: {
select: {
length: true,
recurringEvent: true,
team: {
select: {
slug: true,
@@ -199,8 +265,30 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const bookingObj = Object.assign({}, booking, {
startTime: booking.startTime.toString(),
endTime: booking.endTime.toString(),
eventType: {
...booking.eventType,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
},
});
let recurringInstances = null;
if (booking.eventType?.recurringEvent) {
recurringInstances = await prisma.booking.findMany({
where: {
recurringEventId: booking.recurringEventId,
},
select: {
startTime: true,
endTime: true,
},
});
recurringInstances = recurringInstances.map((recurr) => ({
...recurr,
startTime: recurr.startTime.toString(),
endTime: recurr.endTime.toString(),
}));
}
const profile = {
name: booking.eventType?.team?.name || booking.user?.name || null,
slug: booking.eventType?.team?.slug || booking.user?.username || null,
@@ -212,6 +300,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
props: {
profile,
booking: bookingObj,
recurringInstances,
cancellationAllowed:
(!!session?.user && session.user?.id === booking.user?.id) || booking.startTime >= new Date(),
trpcState: ssr.dehydrate(),
+5 -2
View File
@@ -12,7 +12,7 @@ export default function CancelSuccess() {
const { t } = useLocale();
// Get router variables
const router = useRouter();
const { title, name, eventPage } = router.query;
const { title, name, eventPage, recurring } = router.query;
const { data: session, status } = useSession();
const loading = status === "loading";
return (
@@ -52,7 +52,10 @@ export default function CancelSuccess() {
<div className="mt-5">
{!loading && !session?.user && <Button href={eventPage as string}>Pick another</Button>}
{!loading && session?.user && (
<Button data-testid="back-to-bookings" href="/bookings" StartIcon={ArrowLeftIcon}>
<Button
data-testid="back-to-bookings"
href={recurring !== "" ? "/bookings/recurring" : "/bookings"}
StartIcon={ArrowLeftIcon}>
{t("back_to_bookings")}
</Button>
)}
+1 -1
View File
@@ -158,7 +158,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
(eventTypeObject?.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventTypeObject.recurringEvent.count
? recurringEventCountQuery
? parseInt(recurringEventCountQuery)
: eventType.recurringEvent?.count)) ||
null;
+19 -16
View File
@@ -26,6 +26,7 @@ import {
import { parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { localStorage } from "@calcom/lib/webstorage";
import { Prisma } from "@calcom/prisma/client";
import Button from "@calcom/ui/Button";
@@ -270,7 +271,7 @@ export default function Success(props: SuccessProps) {
data-testid="success-page">
{userIsOwner && !isEmbed && (
<div className="-mb-7 ml-9 mt-7">
<Link href="/bookings">
<Link href={eventType.recurringEvent?.count ? "/bookings/recurring" : "/bookings"}>
<a className="flex items-center text-black dark:text-white">
<ArrowLeftIcon className="mr-1 h-4 w-4" /> {t("back_to_bookings")}
</a>
@@ -344,10 +345,9 @@ export default function Success(props: SuccessProps) {
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6">
<RecurringBookings
isReschedule={reschedule === "true"}
eventType={props.eventType}
recurringBookings={props.recurringBookings}
listingStatus={(listingStatus as string) || "upcoming"}
listingStatus={(listingStatus as string) || "recurring"}
date={date}
is24h={is24h}
/>
@@ -429,16 +429,21 @@ export default function Success(props: SuccessProps) {
<button className="underline" onClick={() => setIsCancellationMode(true)}>
{t("cancel")}
</button>
<div className="mx-2">{t("or_lowercase")}</div>
<div className="underline">
<Link href={"/reschedule/" + bookingInfo?.uid}>{t("Reschedule")}</Link>
</div>
{!props.recurringBookings && (
<>
<div className="mx-2">{t("or_lowercase")}</div>
<div className="underline">
<Link href={"/reschedule/" + bookingInfo?.uid}>{t("Reschedule")}</Link>
</div>
</>
)}
</div>
</div>
) : (
<CancelBooking
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title }}
profile={{ name: props.profile.name, slug: props.profile.slug }}
recurringEvent={eventType.recurringEvent}
team={eventType?.team?.name}
setIsCancellationMode={setIsCancellationMode}
theme={isSuccessBookingPage ? props.profile.theme : "light"}
@@ -605,7 +610,6 @@ export default function Success(props: SuccessProps) {
}
type RecurringBookingsProps = {
isReschedule: boolean;
eventType: SuccessProps["eventType"];
recurringBookings: SuccessProps["recurringBookings"];
date: dayjs.Dayjs;
@@ -613,13 +617,7 @@ type RecurringBookingsProps = {
listingStatus: string;
};
function RecurringBookings({
isReschedule = false,
eventType,
recurringBookings,
date,
listingStatus,
}: RecurringBookingsProps) {
function RecurringBookings({ eventType, recurringBookings, date, listingStatus }: RecurringBookingsProps) {
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
const { t } = useLocale();
@@ -627,8 +625,13 @@ function RecurringBookings({
? recurringBookings.sort((a, b) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1))
: null;
return !isReschedule && recurringBookingsSorted && listingStatus === "upcoming" ? (
return recurringBookingsSorted && listingStatus === "recurring" ? (
<>
{eventType.recurringEvent?.count && (
<span className="font-medium">
{getEveryFreqFor({ t, recurringEvent: eventType.recurringEvent })}
</span>
)}
{eventType.recurringEvent?.count &&
recurringBookingsSorted.slice(0, 4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
+1 -1
View File
@@ -97,7 +97,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
(eventType.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
? recurringEventCountQuery
? parseInt(recurringEventCountQuery)
: eventType.recurringEvent.count)) ||
null;
@@ -478,6 +478,7 @@
"danger_zone": "Danger Zone",
"back": "Back",
"cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining",
"apply": "Apply",
"cancel_event": "Cancel event",
"continue": "Continue",
@@ -553,7 +554,11 @@
"repeats_up_to_one": "Repeats up to {{count}} time",
"repeats_up_to_other": "Repeats up to {{count}} times",
"every_for_freq": "Every {{freq}} for",
"event_remaining_one": "{{count}} event remaining",
"event_remaining_other": "{{count}} events remaining",
"repeats_every": "Repeats every",
"occurrence_one": "occurrence",
"occurrence_other": "occurrences",
"weekly_one": "week",
"weekly_other": "weeks",
"monthly_one": "month",
+6 -1
View File
@@ -319,7 +319,12 @@ const loggedInViewerRouter = createProtectedRouter()
// handled separately for each occurrence
OR: [
{
AND: [{ NOT: { recurringEventId: { equals: null } } }, { status: BookingStatus.PENDING }],
AND: [
{ NOT: { recurringEventId: { equals: null } } },
{ NOT: { status: { equals: BookingStatus.PENDING } } },
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
],
},
{
AND: [
+14 -10
View File
@@ -3,22 +3,26 @@ import timezone from "dayjs/plugin/timezone";
import { TFunction } from "next-i18next";
import rrule from "rrule";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { RecurringEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
dayjs.extend(timezone);
function getRecurringWhen(props: { calEvent: CalendarEvent }) {
const t = props.calEvent.attendees[0].language.translate;
const { calEvent: { recurringEvent } = {} } = props;
return recurringEvent?.count && recurringEvent?.freq
? ` - ${t("every_for_freq", {
freq: t(`${rrule.FREQUENCIES[recurringEvent.freq].toString().toLowerCase()}`),
})} ${recurringEvent.count} ${t(`${rrule.FREQUENCIES[recurringEvent.freq].toString().toLowerCase()}`, {
count: recurringEvent.count,
})}`
: "";
function getRecurringWhen({ calEvent }: { calEvent: CalendarEvent }) {
if (calEvent.recurringEvent) {
const t = calEvent.attendees[0].language.translate;
const rruleOptions = new rrule(calEvent.recurringEvent).options;
const recurringEvent: RecurringEvent = {
freq: rruleOptions.freq,
count: rruleOptions.count || 1,
interval: rruleOptions.interval,
};
return ` - ${getEveryFreqFor({ t, recurringEvent })}`;
}
return "";
}
export function WhenInfo(props: { calEvent: CalendarEvent; timeZone: string; t: TFunction }) {
@@ -15,7 +15,11 @@ export const OrganizerRequestEmail = (props: React.ComponentProps<typeof Organiz
<CallToActionTable>
<CallToAction
label={props.calEvent.organizer.language.translate("confirm_or_reject_request")}
href={process.env.NEXT_PUBLIC_WEBAPP_URL + "/bookings/upcoming"}
href={
process.env.NEXT_PUBLIC_WEBAPP_URL + props.calEvent.recurringEvent?.count
? "/bookings/recurring"
: "/bookings/upcoming"
}
/>
</CallToActionTable>
}
@@ -32,7 +32,7 @@ export default class AttendeeScheduledEmail extends BaseEmail {
}
protected getiCalEventAsString(): string | undefined {
// Taking care of recurrence rule beforehand
// Taking care of recurrence rule
let recurrenceRule: string | undefined = undefined;
if (this.calEvent.recurringEvent?.count) {
// ics appends "RRULE:" already, so removing it from RRule generated string
@@ -35,7 +35,9 @@ export default class OrganizerRequestEmail extends OrganizerScheduledEmail {
"someone_requested_an_event",
"",
`${this.calEvent.organizer.language.translate("confirm_or_reject_request")}
${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming"`
${process.env.NEXT_PUBLIC_WEBAPP_URL} + ${
this.calEvent.recurringEvent?.count ? "/bookings/recurring" : "/bookings/upcoming"
}`
);
}
}
@@ -20,7 +20,6 @@ dayjs.extend(toArray);
export default class OrganizerScheduledEmail extends BaseEmail {
calEvent: CalendarEvent;
t: TFunction;
constructor(calEvent: CalendarEvent) {
@@ -31,7 +30,7 @@ export default class OrganizerScheduledEmail extends BaseEmail {
}
protected getiCalEventAsString(): string | undefined {
// Taking care of recurrence rule beforehand
// Taking care of recurrence rule
let recurrenceRule: string | undefined = undefined;
if (this.calEvent.recurringEvent?.count) {
// ics appends "RRULE:" already, so removing it from RRule generated string
+1
View File
@@ -12,6 +12,7 @@
"ics": "^2.31.0",
"next-i18next": "^11.0.0",
"react-hot-toast": "^2.1.0",
"rrule": "^2.6.9",
"tsdav": "2.0.2",
"tslog": "^3.2.1",
"uuid": "^8.3.2"
+45
View File
@@ -0,0 +1,45 @@
import { TFunction } from "next-i18next";
import { Frequency } from "@calcom/prisma/zod-utils";
import { RecurringEvent } from "@calcom/types/Calendar";
export const getRecurringFreq = ({
t,
recurringEvent,
}: {
t: TFunction;
recurringEvent: RecurringEvent;
}): string => {
if (recurringEvent.interval && recurringEvent.freq) {
return t("every_for_freq", {
freq: `${recurringEvent.interval > 1 ? recurringEvent.interval : ""} ${t(
Frequency[recurringEvent.freq].toString().toLowerCase(),
{
count: recurringEvent.interval,
}
)}`,
});
}
return "";
};
export const getEveryFreqFor = ({
t,
recurringEvent,
recurringCount,
recurringFreq,
}: {
t: TFunction;
recurringEvent: RecurringEvent;
recurringCount?: number;
recurringFreq?: string;
}): string => {
if (recurringEvent.freq) {
return `${recurringFreq || getRecurringFreq({ t, recurringEvent })} ${
recurringCount || recurringEvent.count
} ${t("occurrences", {
count: recurringCount || recurringEvent.count,
})}`;
}
return "";
};
+642 -330
View File
File diff suppressed because it is too large Load Diff