feat: make the meeting duration time more visually toggleable (#15422)

* feat: make the meeting duration time more visually toggleable

* final_update

* Update dynamic-booking-pages.e2e.ts

* fix e2e

* fix: e2e test

* fixed

* update

* show selcted item at the time of mount

* fix type error

* Update Duration.tsx

* fix

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
Anik Dhabal Babu
2024-08-19 14:25:02 +00:00
committed by GitHub
co-authored by Udit Takkar Udit Takkar
parent 99b7aaa62d
commit 680e748b7a
7 changed files with 97 additions and 35 deletions
@@ -145,7 +145,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
length: eventData.length,
metadata: {
...eventData.metadata,
multipleDuration: [15, 30, 60],
multipleDuration: [15, 30, 45, 60, 90],
},
},
user: usernames.join("+"),
@@ -72,23 +72,23 @@ test("dynamic booking info prefilled by query params", async ({ page, users }) =
await page.waitForLoadState("networkidle");
const badgeByDurationTestId = (duration: number) => `multiple-choice-${duration}mins`;
const listItemByDurationTestId = (duration: number) => `multiple-choice-${duration}mins`;
let badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
let activeState = await badgeLocator.getAttribute("data-active");
let listItemLocator = await page.getByTestId(listItemByDurationTestId(duration));
let activeState = await listItemLocator.getAttribute("data-active");
expect(activeState).toEqual("true");
duration = 30;
await page.goto(`/${pro.username}+${free.username}?duration=${duration}`);
badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
activeState = await badgeLocator.getAttribute("data-active");
listItemLocator = await page.getByTestId(listItemByDurationTestId(duration));
activeState = await listItemLocator.getAttribute("data-active");
expect(activeState).toEqual("true");
// Check another badge just to ensure its not selected
badgeLocator = await page.getByTestId(badgeByDurationTestId(15));
activeState = await badgeLocator.getAttribute("data-active");
listItemLocator = await page.getByTestId(listItemByDurationTestId(15));
activeState = await listItemLocator.getAttribute("data-active");
expect(activeState).toEqual("false");
});
// eslint-disable-next-line playwright/no-skipped-test
+1
View File
@@ -64,6 +64,7 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
const nonce = randomString(3);
const eventTitle = `my recurring event ${nonce}`;
await createNewEventType(page, { eventTitle });
await page.waitForLoadState("networkidle");
await page.click("[data-testid=vertical-tab-recurring]");
await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeHidden();
@@ -713,6 +713,7 @@
"default_duration_no_options": "Please choose available durations first",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"multiple_duration_timeUnit_short": "{{count}}$t({{unit}}_short)",
"minutes": "Minutes",
"use_cal_ai_to_make_call_description": "Use Cal.ai to get an AI powered phone number or make calls to guests.",
"round_robin": "Round Robin",
@@ -1224,6 +1225,8 @@
"day_timeUnit": "days",
"hour_timeUnit": "hours",
"minute_timeUnit": "mins",
"minute_short": "m",
"hour_short": "h",
"new_workflow_heading": "Create your first workflow",
"new_workflow_description": "Workflows enable you to automate sending reminders and notifications.",
"active_on": "Active on",
@@ -1277,8 +1280,10 @@
"reminder_email": "Reminder: {{eventType}} with {{name}} at {{date}}",
"not_triggering_existing_bookings": "Won't trigger for already existing bookings as user will be asked for phone number when booking the event.",
"minute_one": "{{count}} minute",
"minute_one_short": "{{count}}m",
"minute_other": "{{count}} minutes",
"hour_one": "{{count}} hour",
"hour_one_short": "{{count}}h",
"hour_other": "{{count}} hours",
"invalid_input": "Invalid input",
"broken_video_action": "We could not add the <1>{{location}}</1> meeting link to your scheduled event. Contact your invitees or update your calendar event to add the details. You can either <3> change your location on the event type </3> or try <5>removing and adding the app again.</5>",
@@ -142,7 +142,7 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
switch (block) {
case EventDetailBlocks.DURATION:
return (
<EventMetaBlock key={block} icon="clock">
<EventMetaBlock key={block} icon="clock" className="items-center">
<EventDuration event={event} />
</EventMetaBlock>
);
@@ -1,12 +1,12 @@
import type { TFunction } from "next-i18next";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useIsPlatform } from "@calcom/atoms/monorepo";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import type { BookerEvent } from "@calcom/features/bookings/types";
import classNames from "@calcom/lib/classNames";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui";
import { useShouldShowArrows, Icon } from "@calcom/ui";
/** Render X mins as X hours or X hours Y mins instead of in minutes once >= 60 minutes */
export const getDurationFormatted = (mins: number | undefined, t: TFunction) => {
@@ -19,16 +19,16 @@ export const getDurationFormatted = (mins: number | undefined, t: TFunction) =>
if (mins > 0) {
minStr =
mins === 1
? t("minute_one", { count: 1 })
: t("multiple_duration_timeUnit", { count: mins, unit: "minute" });
? t("minute_one_short", { count: 1 })
: t("multiple_duration_timeUnit_short", { count: mins, unit: "minute" });
}
// format hours string
let hourStr = "";
if (hours > 0) {
hourStr =
hours === 1
? t("hour_one", { count: 1 })
: t("multiple_duration_timeUnit", { count: hours, unit: "hour" });
? t("hour_one_short", { count: 1 })
: t("multiple_duration_timeUnit_short", { count: hours, unit: "hour" });
}
if (hourStr && minStr) return `${hourStr} ${minStr}`;
@@ -41,6 +41,7 @@ export const EventDuration = ({
event: Pick<BookerEvent, "length" | "metadata" | "isDynamic">;
}) => {
const { t } = useLocale();
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
const isPlatform = useIsPlatform();
const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [
state.selectedDuration,
@@ -48,6 +49,20 @@ export const EventDuration = ({
state.state,
]);
const { ref, calculateScroll, leftVisible, rightVisible } = useShouldShowArrows();
const handleLeft = () => {
if (ref.current) {
ref.current.scrollLeft -= 100;
}
};
const handleRight = () => {
if (ref.current) {
ref.current.scrollLeft += 100;
}
};
const isDynamicEvent = "isDynamic" in event && event.isDynamic;
// Sets initial value of selected duration to the default duration.
@@ -57,27 +72,63 @@ export const EventDuration = ({
setSelectedDuration(event.length);
}, [selectedDuration, setSelectedDuration, event.metadata?.multipleDuration, event.length, isDynamicEvent]);
useEffect(() => {
const timeout = setTimeout(() => {
if (selectedDuration && itemRefs.current[selectedDuration]) {
itemRefs.current[selectedDuration]?.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
});
}
}, 100);
return () => clearTimeout(timeout);
}, [selectedDuration]);
if ((!event?.metadata?.multipleDuration && !isDynamicEvent) || isPlatform)
return <>{getDurationFormatted(event.length, t)}</>;
const durations = event?.metadata?.multipleDuration || [15, 30, 60, 90];
return (
<div className="flex flex-wrap gap-2">
{durations
.filter((dur) => state !== "booking" || dur === selectedDuration)
.map((duration) => (
<Badge
data-testId={`multiple-choice-${duration}mins`}
data-active={selectedDuration === duration ? "true" : "false"}
variant="gray"
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
size="md"
key={duration}
onClick={() => setSelectedDuration(duration)}>
{getDurationFormatted(duration, t)}
</Badge>
))}
return selectedDuration ? (
<div className="border-default relative mr-5 flex flex-row items-center justify-between rounded-md border">
{leftVisible && (
<button onClick={handleLeft} className="absolute bottom-0 left-0 flex">
<div className="bg-default flex h-9 w-5 items-center justify-end rounded-md">
<Icon name="chevron-left" className="text-subtle h-4 w-4" />
</div>
<div className="to-default flex h-9 w-5 bg-gradient-to-l from-transparent" />
</button>
)}
<ul
className="bg-default no-scrollbar flex max-w-full items-center gap-0.5 overflow-x-auto rounded-md p-1"
onScroll={(e) => calculateScroll(e)}
ref={ref}>
{durations
.filter((dur) => state !== "booking" || dur === selectedDuration)
.map((duration, index) => (
<li
data-testId={`multiple-choice-${duration}mins`}
data-active={selectedDuration === duration ? "true" : "false"}
key={index}
onClick={() => setSelectedDuration(duration)}
ref={(el) => (itemRefs.current[duration] = el)}
className={classNames(
selectedDuration === duration ? "bg-emphasis" : "hover:text-emphasis",
"text-default cursor-pointer rounded-[4px] px-3 py-1.5 text-sm leading-tight transition"
)}>
<div className="w-max">{getDurationFormatted(duration, t)}</div>
</li>
))}
</ul>
{rightVisible && (
<button onClick={handleRight} className="absolute bottom-0 right-0 flex">
<div className="to-default flex h-9 w-5 bg-gradient-to-r from-transparent" />
<div className="bg-default flex h-9 w-5 items-center justify-end rounded-md">
<Icon name="chevron-right" className="text-subtle h-4 w-4" />
</div>
</button>
)}
</div>
);
) : null;
};
+7 -2
View File
@@ -26,8 +26,13 @@ export function useShouldShowArrows() {
useEffect(() => {
const appCategoryList = ref.current;
if (appCategoryList && appCategoryList.scrollWidth > appCategoryList.clientWidth) {
setShowArrowScroll({ left: false, right: true });
if (appCategoryList) {
const isAtStart = appCategoryList.scrollLeft <= 0;
const isAtEnd = appCategoryList.scrollWidth <= appCategoryList.clientWidth + appCategoryList.scrollLeft;
setShowArrowScroll({
left: !isAtStart,
right: !isAtEnd,
});
}
}, []);