Skip to content

Commit e530ad5

Browse files
feat: add toggleable legend functionality to ChartCard components (calcom#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>
1 parent 882eb85 commit e530ad5

5 files changed

Lines changed: 133 additions & 52 deletions

File tree

packages/features/insights/components/ChartCard.tsx

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { Fragment, type ReactNode } from "react";
3+
import { Fragment, useMemo, type ReactNode } from "react";
44

55
import classNames from "@calcom/ui/classNames";
66
import { PanelCard } from "@calcom/ui/components/card";
@@ -19,6 +19,8 @@ export function ChartCard({
1919
cta,
2020
legend,
2121
legendSize,
22+
enabledLegend,
23+
onSeriesToggle,
2224
children,
2325
className,
2426
titleTooltip,
@@ -28,11 +30,16 @@ export function ChartCard({
2830
cta?: { label: string; onClick: () => void };
2931
legend?: Array<LegendItem>;
3032
legendSize?: LegendSize;
33+
enabledLegend?: Array<LegendItem>;
34+
onSeriesToggle?: (label: string) => void;
3135
className?: string;
3236
titleTooltip?: string;
3337
children: ReactNode;
3438
}) {
35-
const legendComponent = legend && legend.length > 0 ? <Legend items={legend} size={legendSize} /> : null;
39+
const legendComponent =
40+
legend && legend.length > 0 ? (
41+
<Legend items={legend} size={legendSize} enabledItems={enabledLegend} onItemToggle={onSeriesToggle} />
42+
) : null;
3643

3744
return (
3845
<PanelCard
@@ -68,28 +75,54 @@ export function ChartCardItem({
6875
);
6976
}
7077

71-
function Legend({ items, size = "default" }: { items: LegendItem[]; size?: LegendSize }) {
78+
function Legend({
79+
items,
80+
size = "default",
81+
enabledItems,
82+
onItemToggle,
83+
}: {
84+
items: LegendItem[];
85+
size?: LegendSize;
86+
enabledItems?: LegendItem[];
87+
onItemToggle?: (label: string) => void;
88+
}) {
89+
const enabledSet = useMemo(() => new Set((enabledItems ?? []).map((i) => i.label)), [enabledItems]);
90+
const isClickable = Boolean(onItemToggle);
91+
7292
return (
7393
<div className="bg-default flex items-center gap-2 rounded-lg px-1.5 py-1">
74-
{items.map((item, index) => (
75-
<Fragment key={item.label}>
76-
<div
77-
className="relative flex items-center gap-2 rounded-md px-1.5 py-0.5"
78-
style={{ backgroundColor: `${item.color}33` }}>
79-
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
80-
<Tooltip content={item.label}>
81-
<p
82-
className={classNames(
83-
"text-default truncate py-0.5 text-sm font-medium leading-none",
84-
size === "sm" ? "w-16" : ""
85-
)}>
86-
{item.label}
87-
</p>
88-
</Tooltip>
89-
</div>
90-
{index < items.length - 1 && <div className="bg-muted h-5 w-[1px]" />}
91-
</Fragment>
92-
))}
94+
{items.map((item, index) => {
95+
const isEnabled = enabledItems ? enabledSet.has(item.label) : true;
96+
97+
return (
98+
<Fragment key={item.label}>
99+
<button
100+
type="button"
101+
className={classNames(
102+
"relative flex items-center gap-2 rounded-md px-1.5 py-0.5 transition-opacity",
103+
isClickable && "cursor-pointer hover:bg-gray-100",
104+
!isEnabled && "opacity-25"
105+
)}
106+
style={{ backgroundColor: `${item.color}33` }}
107+
aria-pressed={isClickable ? isEnabled : undefined}
108+
aria-label={`Toggle ${item.label}`}
109+
disabled={!isClickable}
110+
onClick={isClickable ? () => onItemToggle?.(item.label) : undefined}>
111+
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
112+
<Tooltip content={item.label}>
113+
<span
114+
className={classNames(
115+
"text-default truncate py-0.5 text-sm font-medium leading-none",
116+
size === "sm" ? "w-16" : ""
117+
)}>
118+
{item.label}
119+
</span>
120+
</Tooltip>
121+
</button>
122+
{index < items.length - 1 && <div className="bg-muted h-5 w-[1px]" />}
123+
</Fragment>
124+
);
125+
})}
93126
</div>
94127
);
95128
}

packages/features/insights/components/booking/EventTrendsChart.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { trpc } from "@calcom/trpc";
77
import type { RouterOutputs } from "@calcom/trpc/react";
88

99
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
10+
import { useToggleableLegend } from "../../hooks/useToggleableLegend";
1011
import { valueFormatter } from "../../lib/valueFormatter";
1112
import { ChartCard } from "../ChartCard";
1213
import { LoadingInsight } from "../LoadingInsights";
@@ -66,6 +67,7 @@ const CustomTooltip = ({
6667
export const EventTrendsChart = () => {
6768
const { t } = useLocale();
6869
const insightsBookingParams = useInsightsBookingParameters();
70+
const { enabledLegend, toggleSeries } = useToggleableLegend(legend);
6971

7072
const {
7173
data: eventTrends,
@@ -84,7 +86,11 @@ export const EventTrendsChart = () => {
8486
if (!isSuccess) return null;
8587

8688
return (
87-
<ChartCard title={t("event_trends")} legend={legend}>
89+
<ChartCard
90+
title={t("event_trends")}
91+
legend={legend}
92+
enabledLegend={enabledLegend}
93+
onSeriesToggle={toggleSeries}>
8894
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
8995
<ResponsiveContainer width="100%" height="100%">
9096
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
@@ -98,7 +104,7 @@ export const EventTrendsChart = () => {
98104
tickFormatter={valueFormatter}
99105
/>
100106
<Tooltip content={<CustomTooltip />} />
101-
{legend.map((item) => (
107+
{enabledLegend.map((item) => (
102108
<Line
103109
key={item.label}
104110
type="linear"

packages/features/insights/components/routing/RoutingFunnel.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/us
44
import { useLocale } from "@calcom/lib/hooks/useLocale";
55
import { trpc } from "@calcom/trpc";
66

7+
import { useToggleableLegend } from "../../hooks/useToggleableLegend";
78
import { ChartCard } from "../ChartCard";
89
import { RoutingFunnelContent, legend } from "./RoutingFunnelContent";
910
import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
1011

1112
export function RoutingFunnel() {
1213
const { t } = useLocale();
1314
const insightsRoutingParams = useInsightsRoutingParameters();
15+
const { enabledLegend, toggleSeries } = useToggleableLegend(legend);
1416
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
1517
insightsRoutingParams,
1618
{
@@ -23,15 +25,23 @@ export function RoutingFunnel() {
2325

2426
if (isLoading || !isSuccess || !data) {
2527
return (
26-
<ChartCard title={t("routing_funnel")} legend={legend}>
28+
<ChartCard
29+
title={t("routing_funnel")}
30+
legend={legend}
31+
enabledLegend={enabledLegend}
32+
onSeriesToggle={toggleSeries}>
2733
<RoutingFunnelSkeleton />
2834
</ChartCard>
2935
);
3036
}
3137

3238
return (
33-
<ChartCard title={t("routing_funnel")} legend={legend}>
34-
<RoutingFunnelContent data={data} />
39+
<ChartCard
40+
title={t("routing_funnel")}
41+
legend={legend}
42+
enabledLegend={enabledLegend}
43+
onSeriesToggle={toggleSeries}>
44+
<RoutingFunnelContent data={data} enabledLegend={enabledLegend} />
3545
</ChartCard>
3646
);
3747
}

packages/features/insights/components/routing/RoutingFunnelContent.tsx

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface RoutingFunnelData {
1414

1515
interface RoutingFunnelContentProps {
1616
data: RoutingFunnelData[];
17+
enabledLegend?: Array<{ label: string; color: string }>;
1718
}
1819

1920
const COLOR = {
@@ -28,8 +29,9 @@ export const legend = [
2829
{ label: "Accepted Bookings", color: COLOR.ACCEPTED },
2930
];
3031

31-
export function RoutingFunnelContent({ data }: RoutingFunnelContentProps) {
32+
export function RoutingFunnelContent({ data, enabledLegend }: RoutingFunnelContentProps) {
3233
const { t } = useLocale();
34+
const activeAreas = enabledLegend || legend;
3335

3436
return (
3537
<ResponsiveContainer width="100%" height={300}>
@@ -38,30 +40,36 @@ export function RoutingFunnelContent({ data }: RoutingFunnelContentProps) {
3840
<XAxis dataKey="name" className="text-xs" axisLine={false} tickLine={false} />
3941
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
4042
<Tooltip content={<CustomTooltip />} />
41-
<Area
42-
type="linear"
43-
name={t("routing_funnel_total_submissions")}
44-
dataKey="totalSubmissions"
45-
stroke={COLOR.TOTAL}
46-
fill={COLOR.TOTAL}
47-
fillOpacity={1}
48-
/>
49-
<Area
50-
type="linear"
51-
name={t("routing_funnel_successful_routings")}
52-
dataKey="successfulRoutings"
53-
stroke={COLOR.SUCCESFUL}
54-
fill={COLOR.SUCCESFUL}
55-
fillOpacity={1}
56-
/>
57-
<Area
58-
type="linear"
59-
name={t("routing_funnel_accepted_bookings")}
60-
dataKey="acceptedBookings"
61-
stroke={COLOR.ACCEPTED}
62-
fill={COLOR.ACCEPTED}
63-
fillOpacity={1}
64-
/>
43+
{activeAreas.some((area) => area.label === "Total Submissions") && (
44+
<Area
45+
type="linear"
46+
name={t("routing_funnel_total_submissions")}
47+
dataKey="totalSubmissions"
48+
stroke={COLOR.TOTAL}
49+
fill={COLOR.TOTAL}
50+
fillOpacity={1}
51+
/>
52+
)}
53+
{activeAreas.some((area) => area.label === "Successful Routings") && (
54+
<Area
55+
type="linear"
56+
name={t("routing_funnel_successful_routings")}
57+
dataKey="successfulRoutings"
58+
stroke={COLOR.SUCCESFUL}
59+
fill={COLOR.SUCCESFUL}
60+
fillOpacity={1}
61+
/>
62+
)}
63+
{activeAreas.some((area) => area.label === "Accepted Bookings") && (
64+
<Area
65+
type="linear"
66+
name={t("routing_funnel_accepted_bookings")}
67+
dataKey="acceptedBookings"
68+
stroke={COLOR.ACCEPTED}
69+
fill={COLOR.ACCEPTED}
70+
fillOpacity={1}
71+
/>
72+
)}
6573
</AreaChart>
6674
</ResponsiveContainer>
6775
);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useState, useMemo, useCallback } from "react";
2+
3+
export const useToggleableLegend = <T extends { label: string }>(legend: T[], initialEnabled?: string[]) => {
4+
const [enabledSeries, setEnabledSeries] = useState<string[]>(
5+
initialEnabled ?? legend.map((item) => item.label)
6+
);
7+
8+
const enabledLegend = useMemo(
9+
() => legend.filter((item) => enabledSeries.includes(item.label)),
10+
[legend, enabledSeries]
11+
);
12+
13+
const toggleSeries = useCallback(
14+
(label: string) => {
15+
setEnabledSeries((prev) => (prev.includes(label) ? prev.filter((s) => s !== label) : [...prev, label]));
16+
},
17+
[setEnabledSeries]
18+
);
19+
20+
return {
21+
enabledLegend,
22+
toggleSeries,
23+
};
24+
};

0 commit comments

Comments
 (0)