fix: improve chart type safety and DRY up chart renderer

This commit is contained in:
TheodorTomas
2026-02-24 22:16:07 +07:00
parent fcfb31a1d3
commit aa9ccd70a0
9 changed files with 150 additions and 153 deletions

View File

@@ -28,16 +28,13 @@ import {
parseQueryToState,
} from "@/modules/ee/analysis/lib/query-builder";
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
import type { AnalyticsResponse, TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import type { AnalyticsResponse, TApiChartType, TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
// Filter out table, map, and scatter charts
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter((type) => !["table", "map", "scatter"].includes(type.id));
interface AdvancedChartBuilderProps {
environmentId: string;
initialChartType?: string;
initialChartType?: TApiChartType;
initialQuery?: TChartQuery;
hidePreview?: boolean;
onChartGenerated?: (data: AnalyticsResponse) => void;
@@ -46,7 +43,7 @@ interface AdvancedChartBuilderProps {
}
type Action =
| { type: "SET_CHART_TYPE"; payload: string }
| { type: "SET_CHART_TYPE"; payload: TApiChartType }
| { type: "SET_MEASURES"; payload: string[] }
| { type: "SET_CUSTOM_MEASURES"; payload: CustomMeasure[] }
| { type: "SET_DIMENSIONS"; payload: string[] }
@@ -141,9 +138,10 @@ export function AdvancedChartBuilder({
useEffect(() => {
if (initialQuery && !isInitialized) {
setIsInitialized(true);
const chartType = state.chartType;
executeQueryAction({
environmentId,
query: initialQuery as TChartQuery,
query: initialQuery,
}).then((result) => {
if (result?.serverError) {
setError(getFormattedErrorMessage(result));
@@ -154,19 +152,14 @@ export function AdvancedChartBuilder({
setChartData(data);
setQuery(initialQuery);
lastStateRef.current = JSON.stringify({
chartType: state.chartType,
chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: initialQuery,
chartType: state.chartType as AnalyticsResponse["chartType"],
data,
};
onChartGenerated(analyticsResponse);
if (onChartGenerated && chartType) {
onChartGenerated({ query: initialQuery, chartType, data });
}
}
});
@@ -202,26 +195,22 @@ export function AdvancedChartBuilder({
return;
}
const chartType = state.chartType;
const updatedQuery = buildCubeQuery(state);
setIsLoading(true);
setError(null);
executeQueryAction({
environmentId,
query: updatedQuery as TChartQuery,
query: updatedQuery,
})
.then((result) => {
const data = Array.isArray(result?.data) ? result.data : [];
if (data.length > 0) {
setChartData(data);
setQuery(updatedQuery);
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: updatedQuery,
chartType: state.chartType as AnalyticsResponse["chartType"],
data,
};
onChartGenerated(analyticsResponse);
if (onChartGenerated && chartType) {
onChartGenerated({ query: updatedQuery, chartType, data });
}
} else if (result?.serverError) {
setError(getFormattedErrorMessage(result));
@@ -282,7 +271,7 @@ export function AdvancedChartBuilder({
const result = await executeQueryAction({
environmentId,
query: cubeQuery as TChartQuery,
query: cubeQuery,
});
if (result?.serverError) {
@@ -296,13 +285,8 @@ export function AdvancedChartBuilder({
setError(null);
toast.success(t("environments.analysis.charts.query_executed_successfully"));
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: cubeQuery,
chartType: state.chartType as AnalyticsResponse["chartType"],
data,
};
onChartGenerated(analyticsResponse);
if (onChartGenerated && state.chartType) {
onChartGenerated({ query: cubeQuery, chartType: state.chartType, data });
}
} else {
throw new Error(t("environments.analysis.charts.no_data_returned"));
@@ -436,7 +420,7 @@ export function AdvancedChartBuilder({
{t("environments.analysis.charts.chart_builder_choose_chart_type")}
</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{AVAILABLE_CHART_TYPES.map((chart) => {
{CHART_TYPES.map((chart) => {
const isSelected = state.chartType === chart.id;
return (
<button
@@ -514,7 +498,7 @@ export function AdvancedChartBuilder({
</div>
)}
{chartData && Array.isArray(chartData) && chartData.length > 0 && !isLoading && (
{chartData && Array.isArray(chartData) && chartData.length > 0 && !isLoading && state.chartType && (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<ChartRenderer chartType={state.chartType} data={chartData} />

View File

@@ -1,6 +1,7 @@
"use client";
import { format, isValid, parseISO } from "date-fns";
import type { ElementType, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Area,
@@ -24,9 +25,21 @@ import {
resolveChartKeys,
} from "@/modules/ee/analysis/charts/lib/chart-utils";
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import type { TApiChartType, TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import type { ChartConfig } from "@/modules/ui/components/chart";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
function formatXAxisTick(value: unknown): string {
if (value == null) return "";
let str: string;
if (typeof value === "string") str = value;
else if (typeof value === "number") str = String(value);
else return "";
const date = parseISO(str);
if (isValid(date)) return format(date, "MMM d, yyyy");
return str;
}
function ChartTooltipRow({ value, dataKey }: Readonly<{ value: unknown; dataKey: string }>) {
return (
<>
@@ -53,20 +66,55 @@ function createTooltipFormatter(dataKey: string) {
}
/** Tooltip content for bar/line/area charts with formatted label and value. Extracted to avoid inline component definitions. */
function CartesianChartTooltip({
dataKey,
formatXAxisTick,
}: Readonly<{ dataKey: string; formatXAxisTick: (value: unknown) => string }>) {
function CartesianChartTooltip({ dataKey }: Readonly<{ dataKey: string }>) {
return <ChartTooltipContent labelFormatter={formatXAxisTick} formatter={createTooltipFormatter(dataKey)} />;
}
/** Shared layout for bar, line, and area charts to avoid duplicating grid/axis/tooltip boilerplate. */
function CartesianChart({
data,
xAxisKey,
dataKey,
chartConfig,
chart: Chart,
children,
}: Readonly<{
data: TChartDataRow[];
xAxisKey: string;
dataKey: string;
chartConfig: ChartConfig;
chart: ElementType;
children: ReactNode;
}>) {
return (
<div className="h-64 w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<Chart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={xAxisKey}
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisTick}
/>
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<CartesianChartTooltip dataKey={dataKey} />} />
{children}
</Chart>
</ChartContainer>
</div>
);
}
interface ChartRendererProps {
chartType: string;
chartType: TApiChartType;
data: TChartDataRow[];
}
export const ChartRenderer = ({ chartType, data }: Readonly<ChartRendererProps>) => {
export function ChartRenderer({ chartType, data }: Readonly<ChartRendererProps>) {
const { t } = useTranslation();
if (!data || data.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
@@ -76,114 +124,63 @@ export const ChartRenderer = ({ chartType, data }: Readonly<ChartRendererProps>)
}
const { xAxisKey, dataKey } = resolveChartKeys(data, chartType);
const chartConfig = {
const chartConfig: ChartConfig = {
[dataKey]: {
label: formatCubeColumnHeader(dataKey),
color: CHART_BRAND_DARK,
},
};
const formatXAxisTick = (value: unknown): string => {
if (value == null) return "";
let str: string;
if (typeof value === "string") str = value;
else if (typeof value === "number") str = String(value);
else return "";
const date = parseISO(str);
if (isValid(date)) return format(date, "MMM d, yyyy");
return str;
};
switch (chartType) {
case "bar":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={xAxisKey}
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisTick}
/>
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
content={<CartesianChartTooltip dataKey={dataKey} formatXAxisTick={formatXAxisTick} />}
/>
<Bar dataKey={dataKey} fill={CHART_BRAND_DARK} radius={4} />
</BarChart>
</ChartContainer>
</div>
<CartesianChart
chart={BarChart}
data={data}
xAxisKey={xAxisKey}
dataKey={dataKey}
chartConfig={chartConfig}>
<Bar dataKey={dataKey} fill={CHART_BRAND_DARK} radius={4} />
</CartesianChart>
);
case "line":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={xAxisKey}
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisTick}
/>
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
content={<CartesianChartTooltip dataKey={dataKey} formatXAxisTick={formatXAxisTick} />}
/>
<Line
type="monotone"
dataKey={dataKey}
stroke={CHART_BRAND_DARK}
strokeWidth={3}
dot={{ fill: CHART_BRAND_DARK, r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ChartContainer>
</div>
<CartesianChart
chart={LineChart}
data={data}
xAxisKey={xAxisKey}
dataKey={dataKey}
chartConfig={chartConfig}>
<Line
type="monotone"
dataKey={dataKey}
stroke={CHART_BRAND_DARK}
strokeWidth={3}
dot={{ fill: CHART_BRAND_DARK, r: 4 }}
activeDot={{ r: 6 }}
/>
</CartesianChart>
);
case "area":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={xAxisKey}
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisTick}
/>
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
content={<CartesianChartTooltip dataKey={dataKey} formatXAxisTick={formatXAxisTick} />}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={CHART_BRAND_DARK}
fill={CHART_BRAND_LIGHT}
fillOpacity={0.4}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</div>
<CartesianChart
chart={AreaChart}
data={data}
xAxisKey={xAxisKey}
dataKey={dataKey}
chartConfig={chartConfig}>
<Area
type="monotone"
dataKey={dataKey}
stroke={CHART_BRAND_DARK}
fill={CHART_BRAND_LIGHT}
fillOpacity={0.4}
strokeWidth={2}
/>
</CartesianChart>
);
case "pie":
case "donut": {
if (!dataKey || !xAxisKey) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
{t("environments.analysis.charts.unable_to_determine_chart_data_structure")}
</div>
);
}
const pieResult = preparePieData(data, dataKey);
if (!pieResult) {
return (
@@ -195,9 +192,9 @@ export const ChartRenderer = ({ chartType, data }: Readonly<ChartRendererProps>)
const { processedData, colors } = pieResult;
return (
<div className="h-64 min-h-[256px] w-full min-w-0">
<div className="h-64 w-full min-w-0">
<ChartContainer config={chartConfig} className="h-full w-full min-w-0">
<PieChart width={400} height={256}>
<PieChart>
<Pie
data={processedData}
dataKey={dataKey}
@@ -234,7 +231,7 @@ export const ChartRenderer = ({ chartType, data }: Readonly<ChartRendererProps>)
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="text-4xl font-bold text-gray-900">{total.toLocaleString()}</div>
<div className="mt-2 text-sm text-gray-500">{dataKey}</div>
<div className="mt-2 text-sm text-gray-500">{formatCubeColumnHeader(dataKey)}</div>
</div>
</div>
);
@@ -246,4 +243,4 @@ export const ChartRenderer = ({ chartType, data }: Readonly<ChartRendererProps>)
</div>
);
}
};
}

View File

@@ -6,7 +6,7 @@ import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query
import { ChartDialogFooterWithModals } from "@/modules/ee/analysis/charts/components/chart-dialog-footer-with-modals";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import type { AnalyticsResponse, TApiChartType } from "@/modules/ee/analysis/types/analysis";
import {
Dialog,
DialogBody,
@@ -24,8 +24,8 @@ interface CreateChartViewProps {
chartData: AnalyticsResponse | null;
chartName: string;
onChartNameChange: (name: string) => void;
selectedChartType: string;
onSelectedChartTypeChange: (type: string) => void;
selectedChartType: TApiChartType | "";
onSelectedChartTypeChange: (type: TApiChartType) => void;
shouldShowAdvancedBuilder: boolean;
onChartGenerated: (data: AnalyticsResponse) => void;
onAdvancedBuilderSave: (savedChartId: string) => void;
@@ -111,7 +111,7 @@ export function CreateChartView({
{shouldShowAdvancedBuilder && (
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={selectedChartType || chartData?.chartType || ""}
initialChartType={selectedChartType || chartData?.chartType}
initialQuery={chartData?.query}
hidePreview={true}
onChartGenerated={handleAdvancedChartGenerated}

View File

@@ -5,7 +5,7 @@ import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/ad
import { ChartDialogFooterWithModals } from "@/modules/ee/analysis/charts/components/chart-dialog-footer-with-modals";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import type { AnalyticsResponse, TApiChartType } from "@/modules/ee/analysis/types/analysis";
import {
Dialog,
DialogBody,
@@ -26,8 +26,8 @@ interface EditChartViewProps {
isLoadingChart?: boolean;
chartName: string;
onChartNameChange: (name: string) => void;
selectedChartType: string;
onChartTypeChange: (type: string) => void;
selectedChartType: TApiChartType | "";
onChartTypeChange: (type: TApiChartType) => void;
onChartGenerated: (data: AnalyticsResponse) => void;
onAdvancedBuilderSave: (savedChartId: string) => void;
onAdvancedBuilderAddToDashboard: (savedChartId: string, dashboardId?: string) => void;
@@ -93,7 +93,7 @@ export function EditChartView({
<ManualChartBuilder selectedChartType={selectedChartType} onChartTypeSelect={onChartTypeChange} />
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={selectedChartType || chartData?.chartType || ""}
initialChartType={selectedChartType || chartData?.chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={onChartGenerated}

View File

@@ -3,14 +3,13 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { CHART_TYPES } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TApiChartType } from "@/modules/ee/analysis/types/analysis";
interface ManualChartBuilderProps {
selectedChartType: string;
onChartTypeSelect: (type: string) => void;
selectedChartType: TApiChartType | "";
onChartTypeSelect: (type: TApiChartType) => void;
}
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter((type) => !["table", "map", "scatter"].includes(type.id));
export function ManualChartBuilder({
selectedChartType,
onChartTypeSelect,
@@ -24,7 +23,7 @@ export function ManualChartBuilder({
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{AVAILABLE_CHART_TYPES.map((chart) => {
{CHART_TYPES.map((chart) => {
const isSelected = selectedChartType === chart.id;
return (
<button

View File

@@ -40,7 +40,7 @@ export function useCreateChartDialog({
onSuccess,
}: Readonly<UseCreateChartDialogProps>) {
const { t } = useTranslation();
const [selectedChartType, setSelectedChartType] = useState<string>("");
const [selectedChartType, setSelectedChartType] = useState<TApiChartType | "">("");
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);

View File

@@ -9,8 +9,13 @@ import {
TableIcon,
} from "lucide-react";
import type React from "react";
import type { TApiChartType } from "@/modules/ee/analysis/types/analysis";
export const CHART_TYPES = [
export const CHART_TYPES: readonly {
readonly id: TApiChartType;
readonly name: string;
readonly icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
}[] = [
{ id: "area", name: "Area Chart", icon: AreaChartIcon },
{ id: "bar", name: "Bar Chart", icon: BarChart3Icon },
{ id: "line", name: "Line Chart", icon: LineChartIcon },
@@ -19,7 +24,7 @@ export const CHART_TYPES = [
{ id: "big_number", name: "Big Number", icon: ActivityIcon },
{ id: "scatter", name: "Scatter Plot", icon: ScatterChart },
{ id: "map", name: "World Map", icon: MapIcon },
] as const;
];
export const CHART_TYPE_ICONS = Object.fromEntries(CHART_TYPES.map(({ id, icon }) => [id, icon])) as Record<
string,

View File

@@ -2,6 +2,7 @@
* Query builder utility to construct Cube.js queries from chart builder state.
*/
import { TChartQuery, TCubeFilter, TMemberFilter, TTimeDimension } from "@formbricks/types/dashboard";
import type { TApiChartType } from "@/modules/ee/analysis/types/analysis";
export interface CustomMeasure {
id?: string;
@@ -25,7 +26,7 @@ export interface TimeDimensionConfig {
}
export interface ChartBuilderState {
chartType: string;
chartType: TApiChartType | "";
selectedMeasures: string[];
customMeasures: CustomMeasure[];
selectedDimensions: string[];
@@ -105,7 +106,7 @@ function isMemberFilter(f: TCubeFilter): f is TMemberFilter {
* Parse a Cube.js query back into ChartBuilderState.
* Preserves absent granularity / dateRange instead of injecting defaults.
*/
export function parseQueryToState(query: TChartQuery, chartType?: string): Partial<ChartBuilderState> {
export function parseQueryToState(query: TChartQuery, chartType?: TApiChartType): Partial<ChartBuilderState> {
const state: Partial<ChartBuilderState> = {
chartType: chartType || "",
selectedMeasures: query.measures || [],

View File

@@ -97,7 +97,18 @@ export type TAddWidgetInput = z.infer<typeof ZAddWidgetInput>;
// ── Charts UI (query execution, AI response) ─────────────────────────────────
export const ZApiChartType = z.enum(["bar", "line", "donut", "kpi", "area", "pie", "big_number"]);
export const ZApiChartType = z.enum([
"bar",
"line",
"donut",
"kpi",
"area",
"pie",
"big_number",
"table",
"scatter",
"map",
]);
export type TApiChartType = z.infer<typeof ZApiChartType>;
/** Row from Cube.js tablePivot - keys are measure/dimension names, values are primitives */