Feat/onboarding video step connection (#8838)
* UI work - WIP scrollable area * Add translations - refactor some components * Add installed text * Disable if there is no currently installed * Extract loader * Fix conditional * Fix E2E * fix typo
This commit is contained in:
parent
738e0f3e50
commit
bdf3e34ea1
|
@ -3,15 +3,16 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { App } from "@calcom/types/App";
|
import type { App } from "@calcom/types/App";
|
||||||
import { Button } from "@calcom/ui";
|
import { Button } from "@calcom/ui";
|
||||||
|
|
||||||
interface ICalendarItem {
|
interface IAppConnectionItem {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
logo: string;
|
logo: string;
|
||||||
type: App["type"];
|
type: App["type"];
|
||||||
|
installed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CalendarItem = (props: ICalendarItem) => {
|
const AppConnectionItem = (props: IAppConnectionItem) => {
|
||||||
const { title, logo, type } = props;
|
const { title, logo, type, installed } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-center justify-between p-5">
|
<div className="flex flex-row items-center justify-between p-5">
|
||||||
|
@ -25,13 +26,14 @@ const CalendarItem = (props: ICalendarItem) => {
|
||||||
<Button
|
<Button
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
disabled={installed}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// Save cookie key to return url step
|
// Save cookie key to return url step
|
||||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||||
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
|
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
|
||||||
}}>
|
}}>
|
||||||
{t("connect")}
|
{installed ? t("installed") : t("connect")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -39,4 +41,4 @@ const CalendarItem = (props: ICalendarItem) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { CalendarItem };
|
export { AppConnectionItem };
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { SkeletonAvatar, SkeletonText, SkeletonButton } from "@calcom/ui";
|
||||||
|
|
||||||
|
export function StepConnectionLoader() {
|
||||||
|
return (
|
||||||
|
<ul className="bg-default divide-subtle border-subtle divide-y rounded-md border p-0 dark:bg-black">
|
||||||
|
{Array.from({ length: 4 }).map((_item, index) => {
|
||||||
|
return (
|
||||||
|
<li className="flex w-full flex-row justify-center border-b-0 py-6" key={index}>
|
||||||
|
<SkeletonAvatar className="mx-6 h-8 w-8 px-4" />
|
||||||
|
<SkeletonText className="ml-1 mr-4 mt-3 h-5 w-full" />
|
||||||
|
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
|
@ -3,11 +3,12 @@ import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { SkeletonAvatar, SkeletonText, SkeletonButton, List } from "@calcom/ui";
|
import { List } from "@calcom/ui";
|
||||||
|
|
||||||
import { CalendarItem } from "../components/CalendarItem";
|
import { AppConnectionItem } from "../components/AppConnectionItem";
|
||||||
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
|
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
|
||||||
import { CreateEventsOnCalendarSelect } from "../components/CreateEventsOnCalendarSelect";
|
import { CreateEventsOnCalendarSelect } from "../components/CreateEventsOnCalendarSelect";
|
||||||
|
import { StepConnectionLoader } from "../components/StepConnectionLoader";
|
||||||
|
|
||||||
interface IConnectCalendarsProps {
|
interface IConnectCalendarsProps {
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
|
@ -60,7 +61,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||||
queryIntegrations.data.items.map((item) => (
|
queryIntegrations.data.items.map((item) => (
|
||||||
<li key={item.title}>
|
<li key={item.title}>
|
||||||
{item.title && item.logo && (
|
{item.title && item.logo && (
|
||||||
<CalendarItem
|
<AppConnectionItem
|
||||||
type={item.type}
|
type={item.type}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
|
@ -72,19 +73,8 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||||
</List>
|
</List>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{queryIntegrations.isLoading && (
|
{queryIntegrations.isLoading && <StepConnectionLoader />}
|
||||||
<ul className="bg-default divide-subtle border-subtle divide-y rounded-md border p-0 dark:bg-black">
|
|
||||||
{[0, 0, 0, 0].map((_item, index) => {
|
|
||||||
return (
|
|
||||||
<li className="flex w-full flex-row justify-center border-b-0 py-6" key={index}>
|
|
||||||
<SkeletonAvatar className="mx-6 h-8 w-8 px-4" />
|
|
||||||
<SkeletonText className="ml-1 mr-4 mt-3 h-5 w-full" />
|
|
||||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="save-calendar-button"
|
data-testid="save-calendar-button"
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||||
|
|
||||||
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { List } from "@calcom/ui";
|
||||||
|
|
||||||
|
import { AppConnectionItem } from "../components/AppConnectionItem";
|
||||||
|
import { StepConnectionLoader } from "../components/StepConnectionLoader";
|
||||||
|
|
||||||
|
interface ConnectedAppStepProps {
|
||||||
|
nextStep: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||||
|
const { nextStep } = props;
|
||||||
|
const { data: queryConnectedVideoApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||||
|
variant: "conferencing",
|
||||||
|
onlyInstalled: false,
|
||||||
|
});
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||||
|
(item) => item.credentialIds.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isLoading && (
|
||||||
|
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
|
||||||
|
{queryConnectedVideoApps?.items &&
|
||||||
|
queryConnectedVideoApps?.items.map((item) => {
|
||||||
|
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
|
||||||
|
return (
|
||||||
|
<li key={item.name}>
|
||||||
|
{item.name && item.logo && (
|
||||||
|
<AppConnectionItem
|
||||||
|
type={item.type}
|
||||||
|
title={item.name}
|
||||||
|
description={item.description}
|
||||||
|
logo={item.logo}
|
||||||
|
installed={item.credentialIds.length > 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <StepConnectionLoader />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="save-video-button"
|
||||||
|
className={classNames(
|
||||||
|
"text-inverted mt-8 flex w-full flex-row justify-center rounded-md border border-black bg-black p-2 text-center text-sm",
|
||||||
|
!hasAnyInstalledVideoApps ? "cursor-not-allowed opacity-20" : ""
|
||||||
|
)}
|
||||||
|
disabled={!hasAnyInstalledVideoApps}
|
||||||
|
onClick={() => nextStep()}>
|
||||||
|
{t("next_step_text")}
|
||||||
|
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ConnectedVideoStep };
|
|
@ -15,6 +15,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
|
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
|
||||||
|
import { ConnectedVideoStep } from "@components/getting-started/steps-views/ConnectedVideoStep";
|
||||||
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
|
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
|
||||||
import UserProfile from "@components/getting-started/steps-views/UserProfile";
|
import UserProfile from "@components/getting-started/steps-views/UserProfile";
|
||||||
import { UserSettings } from "@components/getting-started/steps-views/UserSettings";
|
import { UserSettings } from "@components/getting-started/steps-views/UserSettings";
|
||||||
|
@ -22,7 +23,13 @@ import { UserSettings } from "@components/getting-started/steps-views/UserSettin
|
||||||
export type IOnboardingPageProps = inferSSRProps<typeof getServerSideProps>;
|
export type IOnboardingPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
const INITIAL_STEP = "user-settings";
|
const INITIAL_STEP = "user-settings";
|
||||||
const steps = ["user-settings", "connected-calendar", "setup-availability", "user-profile"] as const;
|
const steps = [
|
||||||
|
"user-settings",
|
||||||
|
"connected-calendar",
|
||||||
|
"connected-video",
|
||||||
|
"setup-availability",
|
||||||
|
"user-profile",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const stepTransform = (step: (typeof steps)[number]) => {
|
const stepTransform = (step: (typeof steps)[number]) => {
|
||||||
const stepIndex = steps.indexOf(step);
|
const stepIndex = steps.indexOf(step);
|
||||||
|
@ -36,9 +43,9 @@ const stepRouteSchema = z.object({
|
||||||
step: z.array(z.enum(steps)).default([INITIAL_STEP]),
|
step: z.array(z.enum(steps)).default([INITIAL_STEP]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: Refactor how steps work to be contained in one array/object. Currently we have steps,initalsteps,headers etc. These can all be in one place
|
||||||
const OnboardingPage = (props: IOnboardingPageProps) => {
|
const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { user } = props;
|
const { user } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
@ -55,6 +62,11 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||||
subtitle: [`${t("connect_your_calendar_instructions")}`],
|
subtitle: [`${t("connect_your_calendar_instructions")}`],
|
||||||
skipText: `${t("connect_calendar_later")}`,
|
skipText: `${t("connect_calendar_later")}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: `${t("connect_your_video_app")}`,
|
||||||
|
subtitle: [`${t("connect_your_video_app_instructions")}`],
|
||||||
|
skipText: `${t("set_up_later")}`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: `${t("set_availability")}`,
|
title: `${t("set_availability")}`,
|
||||||
subtitle: [
|
subtitle: [
|
||||||
|
@ -122,12 +134,15 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||||
|
|
||||||
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={() => goToIndex(2)} />}
|
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={() => goToIndex(2)} />}
|
||||||
|
|
||||||
|
{currentStep === "connected-video" && <ConnectedVideoStep nextStep={() => goToIndex(3)} />}
|
||||||
|
|
||||||
{currentStep === "setup-availability" && (
|
{currentStep === "setup-availability" && (
|
||||||
<SetupAvailability nextStep={() => goToIndex(3)} defaultScheduleId={user.defaultScheduleId} />
|
<SetupAvailability nextStep={() => goToIndex(4)} defaultScheduleId={user.defaultScheduleId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === "user-profile" && <UserProfile user={user} />}
|
{currentStep === "user-profile" && <UserProfile user={user} />}
|
||||||
</StepCard>
|
</StepCard>
|
||||||
|
|
||||||
{headers[currentStepIndex]?.skipText && (
|
{headers[currentStepIndex]?.skipText && (
|
||||||
<div className="flex w-full flex-row justify-center">
|
<div className="flex w-full flex-row justify-center">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -47,10 +47,19 @@ test.describe("Onboarding", () => {
|
||||||
// tests skip button, we don't want to test entire flow.
|
// tests skip button, we don't want to test entire flow.
|
||||||
await page.locator("button[data-testid=skip-step]").click();
|
await page.locator("button[data-testid=skip-step]").click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/.*setup-availability/);
|
await expect(page).toHaveURL(/.*connected-video/);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("step 3", async () => {
|
await test.step("step 3", async () => {
|
||||||
|
const isDisabled = await page.locator("button[data-testid=save-video-button]").isDisabled();
|
||||||
|
await expect(isDisabled).toBe(true);
|
||||||
|
// tests skip button, we don't want to test entire flow.
|
||||||
|
await page.locator("button[data-testid=skip-step]").click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/.*setup-availability/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("step 4", async () => {
|
||||||
const isDisabled = await page.locator("button[data-testid=save-availability]").isDisabled();
|
const isDisabled = await page.locator("button[data-testid=save-availability]").isDisabled();
|
||||||
await expect(isDisabled).toBe(false);
|
await expect(isDisabled).toBe(false);
|
||||||
// same here, skip this step.
|
// same here, skip this step.
|
||||||
|
@ -59,7 +68,7 @@ test.describe("Onboarding", () => {
|
||||||
await expect(page).toHaveURL(/.*user-profile/);
|
await expect(page).toHaveURL(/.*user-profile/);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("step 4", async () => {
|
await test.step("step 5", async () => {
|
||||||
await page.locator("button[type=submit]").click();
|
await page.locator("button[type=submit]").click();
|
||||||
|
|
||||||
// should redirect to /event-types after onboarding
|
// should redirect to /event-types after onboarding
|
||||||
|
|
|
@ -237,6 +237,8 @@
|
||||||
"set_availability": "Set your availability",
|
"set_availability": "Set your availability",
|
||||||
"continue_without_calendar": "Continue without calendar",
|
"continue_without_calendar": "Continue without calendar",
|
||||||
"connect_your_calendar": "Connect your calendar",
|
"connect_your_calendar": "Connect your calendar",
|
||||||
|
"connect_your_video_app":"Connect your video apps",
|
||||||
|
"connect_your_video_app_instructions":"Connect your video apps to use them on your event types.",
|
||||||
"connect_your_calendar_instructions": "Connect your calendar to automatically check for busy times and new events as they’re scheduled.",
|
"connect_your_calendar_instructions": "Connect your calendar to automatically check for busy times and new events as they’re scheduled.",
|
||||||
"set_up_later": "Set up later",
|
"set_up_later": "Set up later",
|
||||||
"current_time": "Current time",
|
"current_time": "Current time",
|
||||||
|
|
|
@ -56,8 +56,7 @@ export const EventTypeCustomInputType = {
|
||||||
PHONE: "PHONE",
|
PHONE: "PHONE",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EventTypeCustomInputType =
|
export type EventTypeCustomInputType = (typeof EventTypeCustomInputType)[keyof typeof EventTypeCustomInputType];
|
||||||
(typeof EventTypeCustomInputType)[keyof typeof EventTypeCustomInputType];
|
|
||||||
|
|
||||||
export const ReminderType = {
|
export const ReminderType = {
|
||||||
PENDING_BOOKING_CONFIRMATION: "PENDING_BOOKING_CONFIRMATION",
|
PENDING_BOOKING_CONFIRMATION: "PENDING_BOOKING_CONFIRMATION",
|
||||||
|
@ -78,6 +77,7 @@ export const WebhookTriggerEvents = {
|
||||||
BOOKING_CANCELLED: "BOOKING_CANCELLED",
|
BOOKING_CANCELLED: "BOOKING_CANCELLED",
|
||||||
FORM_SUBMITTED: "FORM_SUBMITTED",
|
FORM_SUBMITTED: "FORM_SUBMITTED",
|
||||||
MEETING_ENDED: "MEETING_ENDED",
|
MEETING_ENDED: "MEETING_ENDED",
|
||||||
|
RECORDING_READY: "RECORDING_READY",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type WebhookTriggerEvents = (typeof WebhookTriggerEvents)[keyof typeof WebhookTriggerEvents];
|
export type WebhookTriggerEvents = (typeof WebhookTriggerEvents)[keyof typeof WebhookTriggerEvents];
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { CSSProperties, PropsWithChildren } from "react";
|
||||||
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
|
||||||
|
const ScrollableArea = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
|
||||||
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isOverflowingY, setIsOverflowingY] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollableElement = scrollableRef.current;
|
||||||
|
|
||||||
|
if (scrollableElement) {
|
||||||
|
const isElementOverflowing = scrollableElement.scrollHeight > scrollableElement.clientHeight;
|
||||||
|
console.log({ isElementOverflowing });
|
||||||
|
setIsOverflowingY(isElementOverflowing);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const overflowIndicatorStyles = {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "30px",
|
||||||
|
background: "linear-gradient(to bottom, transparent, gray 40px)",
|
||||||
|
zIndex: 10,
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollableRef}
|
||||||
|
className={classNames(
|
||||||
|
"scroll-bar overflow-y-scroll ",
|
||||||
|
isOverflowingY && " relative ",
|
||||||
|
className // Pass in your max-w / max-h
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
{isOverflowingY && <div style={overflowIndicatorStyles} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ScrollableArea };
|
|
@ -131,3 +131,4 @@ export { default as ImageUploader } from "./components/image-uploader/ImageUploa
|
||||||
export type { ButtonColor } from "./components/button/Button";
|
export type { ButtonColor } from "./components/button/Button";
|
||||||
export { CreateButton } from "./components/createButton";
|
export { CreateButton } from "./components/createButton";
|
||||||
export { useCalcomTheme } from "./styles/useCalcomTheme";
|
export { useCalcomTheme } from "./styles/useCalcomTheme";
|
||||||
|
export { ScrollableArea } from "./components/scrollable/ScrollableArea";
|
||||||
|
|
Loading…
Reference in New Issue
Block a user