feat: add toggleable legend functionality to ChartCard components (#23390)

* feat: add toggleable legend functionality to ChartCard components

- Create useToggleableLegend hook for managing enabled/disabled series
- Update ChartCard to accept enabledLegend and onSeriesToggle props
- Add click handlers and visual feedback to Legend component
- Apply toggleable functionality to EventTrendsChart and RoutingFunnel
- Maintain backwards compatibility with existing charts

Features:
- Visual feedback: 50% opacity for disabled items, hover effects, cursor pointer
- Interactive legend items toggle chart series on/off
- Hook uses TypeScript generics for different legend item types
- Conditional rendering of chart series based on enabled legend items
- Full backwards compatibility - existing charts work unchanged

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* address feedback

* address feedback

* do not use !

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae LeeandDevin AI authored and GitHub committed 2025-09-01 14:54:30 +00:00
1 parent 882eb85a33
commit e530ad5412
5 files changed
+133 -52

No files matched your search

@@ -1,6 +1,6 @@
"use client";
import { Fragment, type ReactNode } from "react";
import { Fragment, useMemo, type ReactNode } from "react";
import classNames from "@calcom/ui/classNames";
import { PanelCard } from "@calcom/ui/components/card";
@@ -19,6 +19,8 @@ export function ChartCard({
cta,
legend,
legendSize,
enabledLegend,
onSeriesToggle,
children,
className,
titleTooltip,
@@ -28,11 +30,16 @@ export function ChartCard({
cta?: { label: string; onClick: () => void };
legend?: Array<LegendItem>;
legendSize?: LegendSize;
enabledLegend?: Array<LegendItem>;
onSeriesToggle?: (label: string) => void;
className?: string;
titleTooltip?: string;
children: ReactNode;
}) {
const legendComponent = legend && legend.length > 0 ? <Legend items={legend} size={legendSize} /> : null;
const legendComponent =
legend && legend.length > 0 ? (
<Legend items={legend} size={legendSize} enabledItems={enabledLegend} onItemToggle={onSeriesToggle} />
) : null;
return (
<PanelCard
@@ -68,28 +75,54 @@ export function ChartCardItem({
);
}
function Legend({ items, size = "default" }: { items: LegendItem[]; size?: LegendSize }) {
function Legend({
items,
size = "default",
enabledItems,
onItemToggle,
}: {
items: LegendItem[];
size?: LegendSize;
enabledItems?: LegendItem[];
onItemToggle?: (label: string) => void;
}) {
const enabledSet = useMemo(() => new Set((enabledItems ?? []).map((i) => i.label)), [enabledItems]);
const isClickable = Boolean(onItemToggle);
return (
<div className="bg-default flex items-center gap-2 rounded-lg px-1.5 py-1">
{items.map((item, index) => (
<Fragment key={item.label}>
<div
className="relative flex items-center gap-2 rounded-md px-1.5 py-0.5"
style={{ backgroundColor: `${item.color}33` }}>
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<Tooltip content={item.label}>
<p
className={classNames(
"text-default truncate py-0.5 text-sm font-medium leading-none",
size === "sm" ? "w-16" : ""
)}>
{item.label}
</p>
</Tooltip>
</div>
{index < items.length - 1 && <div className="bg-muted h-5 w-[1px]" />}
</Fragment>
))}
{items.map((item, index) => {
const isEnabled = enabledItems ? enabledSet.has(item.label) : true;
return (
<Fragment key={item.label}>
<button
type="button"
className={classNames(
"relative flex items-center gap-2 rounded-md px-1.5 py-0.5 transition-opacity",
isClickable && "cursor-pointer hover:bg-gray-100",
!isEnabled && "opacity-25"
)}
style={{ backgroundColor: `${item.color}33` }}
aria-pressed={isClickable ? isEnabled : undefined}
aria-label={`Toggle ${item.label}`}
disabled={!isClickable}
onClick={isClickable ? () => onItemToggle?.(item.label) : undefined}>
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<Tooltip content={item.label}>
<span
className={classNames(
"text-default truncate py-0.5 text-sm font-medium leading-none",
size === "sm" ? "w-16" : ""
)}>
{item.label}
</span>
</Tooltip>
</button>
{index < items.length - 1 && <div className="bg-muted h-5 w-[1px]" />}
</Fragment>
);
})}
</div>
);
}
@@ -7,6 +7,7 @@ import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { useToggleableLegend } from "../../hooks/useToggleableLegend";
import { valueFormatter } from "../../lib/valueFormatter";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
@@ -66,6 +67,7 @@ const CustomTooltip = ({
export const EventTrendsChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { enabledLegend, toggleSeries } = useToggleableLegend(legend);
const {
data: eventTrends,
@@ -84,7 +86,11 @@ export const EventTrendsChart = () => {
if (!isSuccess) return null;
return (
<ChartCard title={t("event_trends")} legend={legend}>
<ChartCard
title={t("event_trends")}
legend={legend}
enabledLegend={enabledLegend}
onSeriesToggle={toggleSeries}>
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
@@ -98,7 +104,7 @@ export const EventTrendsChart = () => {
tickFormatter={valueFormatter}
/>
<Tooltip content={<CustomTooltip />} />
{legend.map((item) => (
{enabledLegend.map((item) => (
<Line
key={item.label}
type="linear"
@@ -4,6 +4,7 @@ import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/us
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useToggleableLegend } from "../../hooks/useToggleableLegend";
import { ChartCard } from "../ChartCard";
import { RoutingFunnelContent, legend } from "./RoutingFunnelContent";
import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
@@ -11,6 +12,7 @@ import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
export function RoutingFunnel() {
const { t } = useLocale();
const insightsRoutingParams = useInsightsRoutingParameters();
const { enabledLegend, toggleSeries } = useToggleableLegend(legend);
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
insightsRoutingParams,
{
@@ -23,15 +25,23 @@ export function RoutingFunnel() {
if (isLoading || !isSuccess || !data) {
return (
<ChartCard title={t("routing_funnel")} legend={legend}>
<ChartCard
title={t("routing_funnel")}
legend={legend}
enabledLegend={enabledLegend}
onSeriesToggle={toggleSeries}>
<RoutingFunnelSkeleton />
</ChartCard>
);
}
return (
<ChartCard title={t("routing_funnel")} legend={legend}>
<RoutingFunnelContent data={data} />
<ChartCard
title={t("routing_funnel")}
legend={legend}
enabledLegend={enabledLegend}
onSeriesToggle={toggleSeries}>
<RoutingFunnelContent data={data} enabledLegend={enabledLegend} />
</ChartCard>
);
}
@@ -14,6 +14,7 @@ interface RoutingFunnelData {
interface RoutingFunnelContentProps {
data: RoutingFunnelData[];
enabledLegend?: Array<{ label: string; color: string }>;
}
const COLOR = {
@@ -28,8 +29,9 @@ export const legend = [
{ label: "Accepted Bookings", color: COLOR.ACCEPTED },
];
export function RoutingFunnelContent({ data }: RoutingFunnelContentProps) {
export function RoutingFunnelContent({ data, enabledLegend }: RoutingFunnelContentProps) {
const { t } = useLocale();
const activeAreas = enabledLegend || legend;
return (
<ResponsiveContainer width="100%" height={300}>
@@ -38,30 +40,36 @@ export function RoutingFunnelContent({ data }: RoutingFunnelContentProps) {
<XAxis dataKey="name" className="text-xs" axisLine={false} tickLine={false} />
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
<Tooltip content={<CustomTooltip />} />
<Area
type="linear"
name={t("routing_funnel_total_submissions")}
dataKey="totalSubmissions"
stroke={COLOR.TOTAL}
fill={COLOR.TOTAL}
fillOpacity={1}
/>
<Area
type="linear"
name={t("routing_funnel_successful_routings")}
dataKey="successfulRoutings"
stroke={COLOR.SUCCESFUL}
fill={COLOR.SUCCESFUL}
fillOpacity={1}
/>
<Area
type="linear"
name={t("routing_funnel_accepted_bookings")}
dataKey="acceptedBookings"
stroke={COLOR.ACCEPTED}
fill={COLOR.ACCEPTED}
fillOpacity={1}
/>
{activeAreas.some((area) => area.label === "Total Submissions") && (
<Area
type="linear"
name={t("routing_funnel_total_submissions")}
dataKey="totalSubmissions"
stroke={COLOR.TOTAL}
fill={COLOR.TOTAL}
fillOpacity={1}
/>
)}
{activeAreas.some((area) => area.label === "Successful Routings") && (
<Area
type="linear"
name={t("routing_funnel_successful_routings")}
dataKey="successfulRoutings"
stroke={COLOR.SUCCESFUL}
fill={COLOR.SUCCESFUL}
fillOpacity={1}
/>
)}
{activeAreas.some((area) => area.label === "Accepted Bookings") && (
<Area
type="linear"
name={t("routing_funnel_accepted_bookings")}
dataKey="acceptedBookings"
stroke={COLOR.ACCEPTED}
fill={COLOR.ACCEPTED}
fillOpacity={1}
/>
)}
</AreaChart>
</ResponsiveContainer>
);
@@ -0,0 +1,24 @@
import { useState, useMemo, useCallback } from "react";
export const useToggleableLegend = <T extends { label: string }>(legend: T[], initialEnabled?: string[]) => {
const [enabledSeries, setEnabledSeries] = useState<string[]>(
initialEnabled ?? legend.map((item) => item.label)
);
const enabledLegend = useMemo(
() => legend.filter((item) => enabledSeries.includes(item.label)),
[legend, enabledSeries]
);
const toggleSeries = useCallback(
(label: string) => {
setEnabledSeries((prev) => (prev.includes(label) ? prev.filter((s) => s !== label) : [...prev, label]));
},
[setEnabledSeries]
);
return {
enabledLegend,
toggleSeries,
};
};