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:
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,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user