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:
co-authored by
Udit Takkar
Udit Takkar
parent
99b7aaa62d
commit
680e748b7a
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user