* refactor: apply biome formatting to apps/web (batch 1) Formats apps/web non-modules directories: - apps/web/app - apps/web/lib - apps/web/pages - apps/web/styles - apps/web/server - apps/web/test - Root-level .ts and .mjs files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: apply biome formatting to apps/web/modules (batch 2) Formats smaller apps/web/modules directories: - onboarding, data-table, users, apps, auth - integration-attribute-sync, shell, availability - feature-flags, team, webhooks, getting-started - feature-opt-in, troubleshooter, schedules, notifications - signup-view.tsx Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: apply biome formatting to apps/web/modules (batch 3) Formats medium apps/web/modules directories: - bookings - event-types - insights - embed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: apply biome formatting to apps/web/modules/ee (batch 4) Formats apps/web/modules/ee directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: apply biome formatting to apps/web (batch 5) Formats remaining apps/web directories: - apps/web/modules/settings - apps/web/modules/booking-audit - apps/web/playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: apply biome formatting to apps/web/components (batch 6) Formats remaining apps/web/components files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: apply biome formatting to new apps/web files from main Formats newly added/modified files from main merge: - WorkflowStepContainer.tsx (resolved merge conflicts) - Newly moved files (blocklist, form-builder, schedules, etc.) - Other files modified in main Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: apply biome formatting to new apps/web files from main Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import posthog from "posthog-js";
|
|
import React, { Fragment, useState, useEffect } from "react";
|
|
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
|
import { sessionStorage } from "@calcom/lib/webstorage";
|
|
import classNames from "@calcom/ui/classNames";
|
|
import { Badge } from "@calcom/ui/components/badge";
|
|
import { Icon } from "@calcom/ui/components/icon";
|
|
import type { IconName } from "@calcom/ui/components/icon";
|
|
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
|
|
|
import { useShouldDisplayNavigationItem } from "./useShouldDisplayNavigationItem";
|
|
|
|
const usePersistedExpansionState = (itemName: string) => {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const stored = sessionStorage.getItem(`nav-expansion-${itemName}`);
|
|
if (stored !== null) {
|
|
setIsExpanded(JSON.parse(stored));
|
|
}
|
|
}, [itemName]);
|
|
|
|
const setPersistedExpansion = (expanded: boolean) => {
|
|
setIsExpanded(expanded);
|
|
sessionStorage.setItem(`nav-expansion-${itemName}`, JSON.stringify(expanded));
|
|
};
|
|
|
|
return [isExpanded, setPersistedExpansion] as const;
|
|
};
|
|
|
|
const trackNavigationClick = (itemName: string, parentItemName?: string) => {
|
|
posthog.capture("navigation_item_clicked", {
|
|
item_name: itemName,
|
|
parent_name: parentItemName,
|
|
});
|
|
};
|
|
|
|
export type NavigationItemType = {
|
|
name: string;
|
|
href: string;
|
|
isLoading?: boolean;
|
|
onClick?: React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>;
|
|
target?: HTMLAnchorElement["target"];
|
|
badge?: React.ReactNode;
|
|
icon?: IconName;
|
|
child?: NavigationItemType[];
|
|
pro?: true;
|
|
onlyMobile?: boolean;
|
|
onlyDesktop?: boolean;
|
|
moreOnMobile?: boolean;
|
|
isCurrent?: ({
|
|
item,
|
|
isChild,
|
|
pathname,
|
|
}: {
|
|
item: Pick<NavigationItemType, "href">;
|
|
isChild?: boolean;
|
|
pathname: string | null;
|
|
}) => boolean;
|
|
};
|
|
|
|
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, pathname }) => {
|
|
return isChild ? item.href === pathname : item.href ? (pathname?.startsWith(item.href) ?? false) : false;
|
|
};
|
|
|
|
export const NavigationItem: React.FC<{
|
|
index?: number;
|
|
item: NavigationItemType;
|
|
isChild?: boolean;
|
|
}> = (props) => {
|
|
const { item, isChild } = props;
|
|
const { t, isLocaleReady } = useLocale();
|
|
const pathname = usePathname();
|
|
const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent;
|
|
const current = isCurrent({ isChild: !!isChild, item, pathname });
|
|
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
|
|
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
|
|
|
|
const isTablet = useMediaQuery("(max-width: 1024px)");
|
|
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
|
|
|
if (!shouldDisplayNavigationItem) return null;
|
|
|
|
const hasChildren = item.child && item.child.length > 0;
|
|
const hasActiveChild =
|
|
hasChildren && item.child?.some((child) => isCurrent({ isChild: true, item: child, pathname }));
|
|
const shouldShowChildren = isExpanded || hasActiveChild || isCurrent({ pathname, isChild, item });
|
|
const shouldShowChevron = hasChildren && !hasActiveChild;
|
|
const isParentNavigationItem = hasChildren && !isChild;
|
|
|
|
return (
|
|
<Fragment>
|
|
{isParentNavigationItem ? (
|
|
<Tooltip
|
|
side="right"
|
|
open={isTooltipOpen}
|
|
content={
|
|
hasChildren ? (
|
|
<div className="stack-y-1 pointer-events-auto flex flex-col p-1">
|
|
<span className="text-subtle px-2 text-xs font-semibold uppercase tracking-wide">
|
|
{t(item.name)}
|
|
</span>
|
|
<div className="flex flex-col gap-1">
|
|
{item.child?.map((childItem) => {
|
|
const childIsCurrent =
|
|
typeof childItem.isCurrent === "function"
|
|
? childItem.isCurrent({
|
|
isChild: true,
|
|
item: childItem,
|
|
pathname,
|
|
})
|
|
: defaultIsCurrent({
|
|
isChild: true,
|
|
item: childItem,
|
|
pathname,
|
|
});
|
|
return (
|
|
<Link
|
|
key={childItem.name}
|
|
href={childItem.href}
|
|
aria-current={childIsCurrent ? "page" : undefined}
|
|
onClick={() => {
|
|
setIsTooltipOpen(false);
|
|
trackNavigationClick(childItem.name, item.name);
|
|
}}
|
|
className={classNames(
|
|
"group relative block rounded-md px-3 py-1 text-sm font-medium",
|
|
childIsCurrent
|
|
? "bg-emphasis text-white"
|
|
: "hover:bg-emphasis text-mute hover:text-emphasis"
|
|
)}>
|
|
{t(childItem.name)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
t(item.name)
|
|
)
|
|
}
|
|
className="lg:hidden">
|
|
<button
|
|
data-test-id={item.name}
|
|
aria-label={t(item.name)}
|
|
aria-expanded={isExpanded}
|
|
aria-current={current ? "page" : undefined}
|
|
onClick={() => {
|
|
if (isTablet && hasChildren) {
|
|
setIsTooltipOpen(!isTooltipOpen);
|
|
} else {
|
|
setIsExpanded(!isExpanded);
|
|
}
|
|
}}
|
|
className={classNames(
|
|
"todesktop:py-[7px] text-default group relative flex w-full items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
|
|
"aria-[aria-current='page']:bg-transparent!",
|
|
"[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
|
|
"md:justify-center lg:justify-start",
|
|
isLocaleReady
|
|
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
|
|
: ""
|
|
)}>
|
|
{item.icon && (
|
|
<div className="relative">
|
|
<Icon
|
|
name={item.isLoading ? "rotate-cw" : item.icon}
|
|
className={classNames(
|
|
"todesktop:!text-blue-500 h-4 w-4 shrink-0 lg:ltr:mr-2 lg:rtl:ml-2",
|
|
item.isLoading && "animate-spin"
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
{shouldShowChevron && (
|
|
<Icon
|
|
name={isExpanded ? "chevron-up" : "chevron-down"}
|
|
className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-subtle p-0.5 lg:hidden"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{isLocaleReady ? (
|
|
<span
|
|
className="hidden w-full justify-between truncate text-ellipsis lg:flex"
|
|
data-testid={`${item.name}-test`}>
|
|
{t(item.name)}
|
|
{item.badge && item.badge}
|
|
</span>
|
|
) : (
|
|
<SkeletonText className="h-[20px] w-full" />
|
|
)}
|
|
{shouldShowChevron && (
|
|
<Icon
|
|
name={isExpanded ? "chevron-up" : "chevron-down"}
|
|
className="ml-auto hidden h-4 w-4 lg:block"
|
|
/>
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip side="right" content={t(item.name)} className="lg:hidden">
|
|
<Link
|
|
data-test-id={item.name}
|
|
onClick={() => trackNavigationClick(item.name)}
|
|
href={item.href}
|
|
aria-label={t(item.name)}
|
|
target={item.target}
|
|
className={classNames(
|
|
"todesktop:py-[7px] text-default group flex items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
|
|
item.child
|
|
? `aria-[aria-current='page']:bg-transparent!`
|
|
: `[&[aria-current='page']]:bg-emphasis`,
|
|
isChild
|
|
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${
|
|
props.index === 0 ? "mt-0" : "mt-1 hover:mt-1 [&[aria-current='page']]:mt-1"
|
|
}`
|
|
: "[&[aria-current='page']]:text-emphasis mt-0.5 text-sm md:justify-center lg:justify-start",
|
|
isLocaleReady
|
|
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
|
|
: ""
|
|
)}
|
|
aria-current={current ? "page" : undefined}>
|
|
{item.icon && (
|
|
<Icon
|
|
name={item.isLoading ? "rotate-cw" : item.icon}
|
|
className={classNames(
|
|
"todesktop:!text-blue-500 h-4 w-4 shrink-0 aria-[aria-current='page']:text-inherit lg:ltr:mr-2 lg:rtl:ml-2",
|
|
item.isLoading && "animate-spin"
|
|
)}
|
|
aria-hidden="true"
|
|
aria-current={current ? "page" : undefined}
|
|
/>
|
|
)}
|
|
{isLocaleReady ? (
|
|
<span
|
|
className="hidden w-full justify-between truncate text-ellipsis lg:flex"
|
|
data-testid={`${item.name}-test`}>
|
|
{t(item.name)}
|
|
{item.badge && item.badge}
|
|
</span>
|
|
) : (
|
|
<SkeletonText className="h-[20px] w-full" />
|
|
)}
|
|
</Link>
|
|
</Tooltip>
|
|
)}
|
|
{hasChildren && (
|
|
<div
|
|
className={classNames(
|
|
"grid transition-all duration-300 ease-in-out",
|
|
shouldShowChildren ? "grid-rows-[1fr] opacity-100 visible" : "grid-rows-[0fr] opacity-0 invisible"
|
|
)}
|
|
aria-hidden={!shouldShowChildren}>
|
|
<div className="overflow-hidden">
|
|
{item.child?.map((item, index) => (
|
|
<NavigationItem index={index} key={item.name} item={item} isChild />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
};
|
|
|
|
export const MobileNavigationItem: React.FC<{
|
|
item: NavigationItemType;
|
|
isChild?: boolean;
|
|
}> = (props) => {
|
|
const { item, isChild } = props;
|
|
const pathname = usePathname();
|
|
const { t, isLocaleReady } = useLocale();
|
|
const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent;
|
|
const current = isCurrent({ isChild: !!isChild, item, pathname });
|
|
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
|
|
|
|
if (!shouldDisplayNavigationItem) return null;
|
|
return (
|
|
<Link
|
|
key={item.name}
|
|
href={item.href}
|
|
target={item.target}
|
|
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted bg-transparent! relative my-2 min-w-0 flex-1 overflow-hidden rounded-md p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
|
|
aria-current={current ? "page" : undefined}>
|
|
{item.badge && <div className="absolute right-1 top-1">{item.badge}</div>}
|
|
{item.icon && (
|
|
<Icon
|
|
name={item.icon}
|
|
className="[&[aria-current='page']]:text-emphasis mx-auto mb-1 block h-5 w-5 shrink-0 text-center text-inherit"
|
|
aria-hidden="true"
|
|
aria-current={current ? "page" : undefined}
|
|
/>
|
|
)}
|
|
{isLocaleReady ? <span className="block truncate">{t(item.name)}</span> : <SkeletonText />}
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
export const MobileNavigationMoreItem: React.FC<{
|
|
item: NavigationItemType;
|
|
isChild?: boolean;
|
|
}> = (props) => {
|
|
const { item } = props;
|
|
const { t, isLocaleReady } = useLocale();
|
|
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
|
|
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
|
|
|
|
if (!shouldDisplayNavigationItem) return null;
|
|
|
|
const hasChildren = item.child && item.child.length > 0;
|
|
const isActionItem = !item.href && item.onClick;
|
|
|
|
const itemContent = (
|
|
<>
|
|
<span className="text-default flex items-center font-semibold ">
|
|
{item.icon && (
|
|
<Icon name={item.icon} className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
|
|
)}
|
|
{isLocaleReady ? t(item.name) : <SkeletonText />}
|
|
</span>
|
|
{!isActionItem && <Icon name="arrow-right" className="text-subtle h-5 w-5" />}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<li className="border-subtle border-b last:border-b-0" key={item.name}>
|
|
{hasChildren ? (
|
|
<>
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="hover:bg-subtle flex w-full items-center justify-between p-5 text-left transition">
|
|
<span className="text-default flex items-center font-semibold">
|
|
{item.icon && (
|
|
<Icon name={item.icon} className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
|
|
)}
|
|
{isLocaleReady ? t(item.name) : <SkeletonText />}
|
|
</span>
|
|
<Icon name={isExpanded ? "chevron-up" : "chevron-down"} className="text-subtle h-5 w-5" />
|
|
</button>
|
|
<div
|
|
className={classNames(
|
|
"grid transition-all duration-300 ease-in-out",
|
|
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
|
)}>
|
|
<div className="overflow-hidden">
|
|
{item.child && (
|
|
<ul className="bg-subtle">
|
|
{item.child.map((childItem) => (
|
|
<li key={childItem.name} className="border-subtle border-t">
|
|
<Link
|
|
href={childItem.href}
|
|
className="hover:bg-cal-muted flex items-center p-4 pl-12 transition">
|
|
<span className="text-default font-medium">
|
|
{isLocaleReady ? t(childItem.name) : <SkeletonText />}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : isActionItem ? (
|
|
<button
|
|
onClick={item.onClick}
|
|
className="hover:bg-subtle flex w-full items-center justify-between p-5 text-left transition">
|
|
{itemContent}
|
|
</button>
|
|
) : (
|
|
<Link
|
|
href={item.href}
|
|
target={item.target}
|
|
className="hover:bg-subtle flex items-center justify-between p-5 transition">
|
|
{itemContent}
|
|
</Link>
|
|
)}
|
|
</li>
|
|
);
|
|
};
|